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/app.js ADDED
@@ -0,0 +1,1646 @@
1
+ /* global $ */
2
+ (function () {
3
+ const state = {
4
+ rowCount: 3,
5
+ minRowCount: 3,
6
+ trackWidth: 1000,
7
+ trackHeight: 52,
8
+ clipSeed: 0,
9
+ clips: [],
10
+ pinX: 0,
11
+ renderedFrames: new Set(),
12
+ pinDragging: false,
13
+ previewUrl: null,
14
+ hasTimeline: false,
15
+ hasPlotObjects: false,
16
+ loadingState: false,
17
+ isPlaying: false,
18
+ loopEnabled: false,
19
+ saveVideoPending: false,
20
+ saveVideoInProgress: false,
21
+ saveVideoFileName: null,
22
+ playbackSpeedOptions: [0.5, 1, 2],
23
+ playbackSpeedIndex: 1,
24
+ playTimer: null,
25
+ playWaitingForFrame: false,
26
+ previewRequestId: 0,
27
+ renderRevision: 0,
28
+ pendingPreviewRequestId: 0,
29
+ timelineName: null,
30
+ renderQueue: [],
31
+ queuedFrames: new Set(),
32
+ renderInFlight: false,
33
+ activeDialogClipId: null,
34
+ selectedClipIds: new Set(),
35
+ suppressClipClickUntil: 0,
36
+ previewHeight: 400,
37
+ statusFadeTimer: null,
38
+ };
39
+
40
+ let resetCircleHoverState = function () {};
41
+
42
+ const DIALOG_FIELD_DEFS = {
43
+ start_x: { wrap: "#dlgStartXWrap", input: "#dlgStartX", defaultValue: "", read: () => Number($("#dlgStartX").val() || 0) },
44
+ start_y: { wrap: "#dlgStartYWrap", input: "#dlgStartY", defaultValue: "", read: () => Number($("#dlgStartY").val() || 0) },
45
+ end_x: { wrap: "#dlgEndXWrap", input: "#dlgEndX", defaultValue: "", read: () => Number($("#dlgEndX").val() || 0) },
46
+ end_y: { wrap: "#dlgEndYWrap", input: "#dlgEndY", defaultValue: "", read: () => Number($("#dlgEndY").val() || 0) },
47
+ center_x: { wrap: "#dlgCenterXWrap", input: "#dlgCenterX", defaultValue: "", read: () => $("#dlgCenterX").val() },
48
+ center_y: { wrap: "#dlgCenterYWrap", input: "#dlgCenterY", defaultValue: "", read: () => $("#dlgCenterY").val() },
49
+ start: { wrap: "#dlgStartWrap", input: "#dlgStart", defaultValue: "", read: () => Number($("#dlgStart").val() || 0) },
50
+ end: { wrap: "#dlgEndWrap", input: "#dlgEnd", defaultValue: "", read: () => Number($("#dlgEnd").val() || 0) },
51
+ tween_property: { wrap: "#dlgTweenPropsWrap", input: "#dlgTweenProps", defaultValue: "alpha", read: () => String($("#dlgTweenProps").val() || "alpha") },
52
+ tween_start: { wrap: "#dlgTweenStartsWrap", input: "#dlgTweenStarts", defaultValue: "0", read: () => $("#dlgTweenStarts").val() },
53
+ tween_end: { wrap: "#dlgTweenEndsWrap", input: "#dlgTweenEnds", defaultValue: "1", read: () => $("#dlgTweenEnds").val() },
54
+ zoom: { wrap: "#dlgZoomWrap", input: "#dlgZoom", defaultValue: 1, read: () => Number($("#dlgZoom").val() || 1) },
55
+ xlim_left: { wrap: "#dlgXlimLeftWrap", input: "#dlgXlimLeft", defaultValue: "", read: () => Number($("#dlgXlimLeft").val() || 0) },
56
+ xlim_right: { wrap: "#dlgXlimRightWrap", input: "#dlgXlimRight", defaultValue: "", read: () => Number($("#dlgXlimRight").val() || 0) },
57
+ ylim_bottom: { wrap: "#dlgYlimBottomWrap", input: "#dlgYlimBottom", defaultValue: "", read: () => Number($("#dlgYlimBottom").val() || 0) },
58
+ ylim_top: { wrap: "#dlgYlimTopWrap", input: "#dlgYlimTop", defaultValue: "", read: () => Number($("#dlgYlimTop").val() || 0) },
59
+ ratio_start: { wrap: "#dlgRatioStartWrap", input: "#dlgRatioStart", defaultValue: "[1, 1]", read: () => $("#dlgRatioStart").val() },
60
+ ratio_end: { wrap: "#dlgRatioEndWrap", input: "#dlgRatioEnd", defaultValue: "[1, 1]", read: () => $("#dlgRatioEnd").val() },
61
+ reverse: { wrap: "#dlgReverseWrap", input: "#dlgReverse", defaultValue: false, read: () => $("#dlgReverse").is(":checked") }
62
+ };
63
+
64
+ const DIALOG_TYPE_SCHEMAS = {
65
+ translate: { fields: ["start_x", "start_y", "end_x", "end_y"], showObject: true },
66
+ scale: { fields: ["start_x", "start_y", "end_x", "end_y", "center_x", "center_y"], showObject: true },
67
+ rotate: { fields: ["start", "end", "center_x", "center_y"], showObject: true },
68
+ tween: { fields: ["tween_property", "tween_start", "tween_end"], showObject: true },
69
+ draw: { fields: ['reverse'], showObject: true },
70
+ morph: { fields: [], showObject: true },
71
+ axis_zoom: { fields: ["zoom"], showObject: false },
72
+ axis_limits: { fields: ["xlim_left", "xlim_right", "ylim_bottom", "ylim_top"], showObject: false },
73
+ axis_move: { fields: ["start_x", "start_y", "end_x", "end_y"], showObject: false },
74
+ axis_alpha: { fields: ["start", "end"], showObject: false },
75
+ fig_width_ratio: { fields: ["ratio_start", "ratio_end"], showObject: false },
76
+ fig_height_ratio: { fields: ["ratio_start", "ratio_end"], showObject: false },
77
+ default: { fields: [], showObject: true },
78
+ };
79
+
80
+ function normalizeClipTypeKey(rawType, fallbackType) {
81
+ const text = String(rawType || fallbackType || "").trim().toLowerCase();
82
+ if (!text) {
83
+ return "default";
84
+ }
85
+ return text;
86
+ }
87
+
88
+ function applyDialogSchema(typeKey) {
89
+ const schema = DIALOG_TYPE_SCHEMAS[typeKey] || DIALOG_TYPE_SCHEMAS.default;
90
+ const visible = new Set(schema.fields || []);
91
+
92
+ Object.keys(DIALOG_FIELD_DEFS).forEach((fieldKey) => {
93
+ const def = DIALOG_FIELD_DEFS[fieldKey];
94
+ $(def.wrap).toggle(visible.has(fieldKey));
95
+ });
96
+
97
+ $("#dlgObjectWrap").toggle(Boolean(schema.showObject));
98
+ }
99
+
100
+ function fillDialogFields(properties) {
101
+ Object.keys(DIALOG_FIELD_DEFS).forEach((fieldKey) => {
102
+ const def = DIALOG_FIELD_DEFS[fieldKey];
103
+ const value = properties[fieldKey];
104
+ $(def.input).val(value ?? def.defaultValue);
105
+ });
106
+ }
107
+
108
+ function buildDialogFieldPayload() {
109
+ const payload = {};
110
+ Object.keys(DIALOG_FIELD_DEFS).forEach((fieldKey) => {
111
+ payload[fieldKey] = DIALOG_FIELD_DEFS[fieldKey].read();
112
+ });
113
+ return payload;
114
+ }
115
+
116
+ function previewMinHeight() {
117
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
118
+ const currentPreviewHeight = $("#previewImage").outerHeight() || state.previewHeight;
119
+ const appHeight = $(".app-shell").first().outerHeight(true) || $("body").outerHeight(true) || 0;
120
+ const nonPreviewHeight = Math.max(0, appHeight - currentPreviewHeight);
121
+ return Math.max(120, Math.round(viewportHeight - nonPreviewHeight));
122
+ }
123
+
124
+ function setPreviewHeight(height) {
125
+ const minHeight = previewMinHeight();
126
+ const maxHeight = Math.max(800, minHeight);
127
+ const next = Math.max(minHeight, Math.min(maxHeight, Math.round(height)));
128
+ state.previewHeight = next;
129
+ $("#previewImage").css("height", `${next}px`);
130
+ }
131
+
132
+ function showStatus(message) {
133
+ const $frameText = $("#frameText");
134
+ if (state.statusFadeTimer) {
135
+ clearTimeout(state.statusFadeTimer);
136
+ state.statusFadeTimer = null;
137
+ }
138
+
139
+ $frameText.text(message).removeClass("is-faded");
140
+ state.statusFadeTimer = setTimeout(() => {
141
+ $frameText.addClass("is-faded");
142
+ state.statusFadeTimer = null;
143
+ }, 3000);
144
+ }
145
+
146
+ function apiGet(url) {
147
+ return fetch(url).then((r) => r.json());
148
+ }
149
+
150
+ function apiPost(url, payload) {
151
+ return fetch(url, {
152
+ method: "POST",
153
+ headers: { "Content-Type": "application/json" },
154
+ body: JSON.stringify(payload),
155
+ }).then((r) => r.json());
156
+ }
157
+
158
+ function applyServerState(payload) {
159
+ const prevRevision = state.renderRevision;
160
+ state.trackWidth = Math.max(100, payload.trackWidth);
161
+ state.minRowCount = Math.max(1, payload.minRowCount);
162
+ state.rowCount = Math.max(state.minRowCount, payload.rowCount);
163
+ state.clips = payload.clips;
164
+ state.hasTimeline = payload.hasTimeline;
165
+ state.hasPlotObjects = payload.hasPlotObjects;
166
+ state.renderRevision = payload.renderRevision;
167
+ state.timelineName = payload.timelineName;
168
+
169
+ const existingIds = new Set(state.clips.map((c) => c.id));
170
+ state.selectedClipIds = new Set([...state.selectedClipIds].filter((id) => existingIds.has(id)));
171
+
172
+ if (state.renderRevision !== prevRevision) {
173
+ const invalidateFrom = payload.invalidatedFromFrame;
174
+ state.renderedFrames = new Set([...state.renderedFrames].filter((f) => f < invalidateFrom));
175
+ state.renderQueue = [];
176
+ state.queuedFrames = new Set();
177
+ state.renderInFlight = false;
178
+ }
179
+ }
180
+
181
+ function loadState() {
182
+ state.loadingState = true;
183
+ return apiGet("/api/state")
184
+ .then((payload) => {
185
+ if (payload.error) {
186
+ showStatus(payload.error);
187
+ return;
188
+ }
189
+ applyServerState(payload);
190
+ renderTimeline();
191
+ })
192
+ .catch((err) => {
193
+ showStatus(`Could not load state: ${err}`);
194
+ })
195
+ .finally(() => {
196
+ state.loadingState = false;
197
+ });
198
+ }
199
+
200
+ function setPlayButtonState() {
201
+ const symbol = state.isPlaying ? "⏸" : "▶";
202
+ const label = state.isPlaying ? "Pause" : "Play";
203
+ $("#btnPlayPause")
204
+ .text(symbol)
205
+ .attr("title", `${label}`)
206
+ .attr("aria-label", label);
207
+ }
208
+
209
+ function setLoopButtonState() {
210
+ const symbol = "⟳";
211
+ const label = state.loopEnabled ? "Loop on" : "Loop off";
212
+ $("#btnLoop")
213
+ .text(symbol)
214
+ .attr("title", label)
215
+ .attr("aria-label", label)
216
+ .toggleClass("active", state.loopEnabled);
217
+ }
218
+
219
+ function setSaveButtonState() {
220
+ const active = state.saveVideoPending || state.saveVideoInProgress;
221
+ const label = active ? "Saving video" : "Save video";
222
+ $("#btnSaveVideo")
223
+ .text("⏺︎")
224
+ .attr("title", label)
225
+ .attr("aria-label", label)
226
+ .toggleClass("active", active);
227
+ }
228
+
229
+ function stopSaveVideoMode() {
230
+ state.saveVideoPending = false;
231
+ state.saveVideoFileName = null;
232
+ stopPlayback();
233
+ setSaveButtonState();
234
+ }
235
+
236
+ function currentPlaybackSpeed() {
237
+ return state.playbackSpeedOptions[state.playbackSpeedIndex];
238
+ }
239
+
240
+ function setSpeedButtonState() {
241
+ const speed = currentPlaybackSpeed();
242
+ $("#btnSpeed")
243
+ .text(`x${speed}`)
244
+ .attr("title", `Playback speed x${speed}`)
245
+ .attr("aria-label", `Playback speed x${speed}`);
246
+ }
247
+
248
+ function setPinFrame(frame, renderNow) {
249
+ const nextFrame = Math.max(0, Math.min(state.trackWidth, Math.round(frame)));
250
+ const frameChanged = nextFrame !== state.pinX;
251
+ state.pinX = nextFrame;
252
+ refreshPinGeometry();
253
+ if (!renderNow) {
254
+ return;
255
+ }
256
+
257
+ if (frameChanged || !state.renderedFrames.has(nextFrame)) {
258
+ updatePreview(nextFrame);
259
+ }
260
+ }
261
+
262
+ function framePath(frame) {
263
+ if (!state.timelineName) {
264
+ return null;
265
+ }
266
+ return `/frames/${state.timelineName}_${frame}.png`;
267
+ }
268
+
269
+ function setPreviewImage(frame, requestId) {
270
+ const srcPath = framePath(frame);
271
+ if (!srcPath) {
272
+ return;
273
+ }
274
+ const src = `${srcPath}?rev=${state.renderRevision}&r=${requestId}`;
275
+ $("#previewImage")
276
+ .attr("data-request-id", String(requestId))
277
+ .attr("data-frame", String(frame))
278
+ .attr("src", src);
279
+ }
280
+
281
+ function processRenderQueue() {
282
+ if (state.renderInFlight) {
283
+ return;
284
+ }
285
+ if (state.renderQueue.length === 0) {
286
+ return;
287
+ }
288
+
289
+ const item = state.renderQueue.shift();
290
+ state.queuedFrames.delete(item.frame);
291
+ state.renderInFlight = true;
292
+
293
+ apiPost("/api/render", { frame: item.frame })
294
+ .then((payload) => {
295
+ if (payload.error) {
296
+ throw new Error(payload.error);
297
+ }
298
+ state.renderRevision = payload.renderRevision;
299
+ markFrameRendered(item.frame);
300
+
301
+ if (item.requestId === state.pendingPreviewRequestId) {
302
+ setPreviewImage(item.frame, item.requestId);
303
+ }
304
+ })
305
+ .catch((err) => {
306
+ showStatus(`Render error: ${err.message || err}`);
307
+ })
308
+ .finally(() => {
309
+ state.renderInFlight = false;
310
+ processRenderQueue();
311
+ });
312
+ }
313
+
314
+ function enqueueFrameRender(frame, requestId) {
315
+ if (state.renderedFrames.has(frame)) {
316
+ if (requestId === state.pendingPreviewRequestId) {
317
+ setPreviewImage(frame, requestId);
318
+ }
319
+ return;
320
+ }
321
+
322
+ if (!state.queuedFrames.has(frame)) {
323
+ state.queuedFrames.add(frame);
324
+ state.renderQueue.push({ frame, requestId });
325
+ } else if (requestId === state.pendingPreviewRequestId) {
326
+ for (let i = state.renderQueue.length - 1; i >= 0; i -= 1) {
327
+ if (state.renderQueue[i].frame === frame) {
328
+ state.renderQueue[i].requestId = requestId;
329
+ break;
330
+ }
331
+ }
332
+ }
333
+
334
+ processRenderQueue();
335
+ }
336
+
337
+ function updatePreview(frameOverride) {
338
+ const frame = Math.round(frameOverride ?? state.pinX);
339
+
340
+ if (!state.hasTimeline || !state.hasPlotObjects) {
341
+ showStatus("Frame preview unavailable: pass a Timeline and plot_objects to GUI_web.GUI(...)");
342
+ return;
343
+ }
344
+
345
+ state.previewRequestId += 1;
346
+ const requestId = state.previewRequestId;
347
+ state.pendingPreviewRequestId = requestId;
348
+
349
+ enqueueFrameRender(frame, requestId);
350
+ }
351
+
352
+ function markFrameRendered(frame) {
353
+ state.renderedFrames.add(Math.round(frame));
354
+ renderFrameIndicators();
355
+ }
356
+
357
+ function renderFrameIndicators() {
358
+ const $strip = $("#frameStrip");
359
+ $strip.find(".frame-indicator").remove();
360
+ state.renderedFrames.forEach((frame) => {
361
+ if (frame < 0 || frame > state.trackWidth) {
362
+ return;
363
+ }
364
+ $("<div>")
365
+ .addClass("frame-indicator")
366
+ .css("left", `${frame}px`)
367
+ .appendTo($strip);
368
+ });
369
+ }
370
+
371
+ function renderTimelineTicks() {
372
+ const $ruler = $("#timelineRuler");
373
+ if ($ruler.length === 0) {
374
+ return;
375
+ }
376
+
377
+ $ruler.find(".frame-major-tick, .frame-major-label").remove();
378
+ const visibleWidth = Math.max(state.trackWidth, Math.round($("#timeline").innerWidth() || state.trackWidth));
379
+
380
+ for (let frame = 0; frame <= visibleWidth; frame += 60) {
381
+ $("<div>")
382
+ .addClass("frame-major-tick")
383
+ .css("left", `${frame}px`)
384
+ .appendTo($ruler);
385
+
386
+ $("<div>")
387
+ .addClass("frame-major-label")
388
+ .css("left", `${frame}px`)
389
+ .text(String(frame))
390
+ .appendTo($ruler);
391
+ }
392
+ }
393
+
394
+ function makeTrack(rowIndex) {
395
+ return $("<div>")
396
+ .addClass("track-row")
397
+ .attr("data-row", rowIndex)
398
+ .attr("data-label", `track #${rowIndex + 1}`)
399
+ }
400
+
401
+ function makeAddTrackBar() {
402
+ return $("<div>")
403
+ .addClass("add-track")
404
+ .text("+");
405
+ }
406
+
407
+ function makeTrackControl(rowIndex) {
408
+ const $control = $("<div>").addClass("track-control");
409
+ if (rowIndex < state.minRowCount) {
410
+ return $control;
411
+ }
412
+ return $control.append(
413
+ $("<button>")
414
+ .addClass("delete-track")
415
+ .attr("data-row", rowIndex)
416
+ .attr("title", `Delete track #${rowIndex + 1}`)
417
+ .text("-")
418
+ );
419
+ }
420
+
421
+ function refreshPinGeometry() {
422
+ state.pinX = Math.max(0, Math.min(state.trackWidth, Math.round(state.pinX)));
423
+ const timelineHeight = state.rowCount * (state.trackHeight + 2) + 28;
424
+ $("#timelinePin").css({
425
+ left: `${state.pinX}px`,
426
+ height: `${timelineHeight}px`,
427
+ });
428
+ $("#pinHandle")
429
+ .text(String(Math.round(state.pinX)))
430
+ .attr("title", `Frame ${Math.round(state.pinX)}`)
431
+ .attr("aria-label", `Current frame ${Math.round(state.pinX)}`);
432
+ $("#pinLayer").css({
433
+ width: `${state.trackWidth}px`,
434
+ height: `${timelineHeight}px`,
435
+ });
436
+ $("#timeline").css("--timeline-width", `${state.trackWidth}px`);
437
+ $("#frameStrip").css("width", `${state.trackWidth+4}px`);
438
+ renderTimelineTicks();
439
+ updateTimelineResizeHandlePosition();
440
+ }
441
+
442
+ function updateTimelineResizeHandlePosition() {
443
+ const handleWidth = $("#timelineResizeHandle").outerWidth();
444
+ const x = state.trackWidth - Math.round(handleWidth / 2) + 10;
445
+ const h = Math.max(40, state.rowCount * (state.trackHeight - 2) - 10);
446
+ $("#timelineResizeHandle").css({
447
+ left: `${x}px`,
448
+ height: `${h}px`,
449
+ });
450
+ }
451
+
452
+ function renderClips() {
453
+ $(".track-row").each(function () {
454
+ $(this).find(".clip").remove();
455
+ });
456
+
457
+ state.clips.forEach((clip) => {
458
+ const $row = $(`.track-row[data-row='${clip.row}']`);
459
+ if ($row.length === 0) {
460
+ return;
461
+ }
462
+ var clipType = clip.type.replace('_',' ');
463
+ var clipHTML = clipType;
464
+ if (clip.plotObjectName != "None"){
465
+ clipHTML += '<br/><span>' + clip.plotObjectName + '</span>';
466
+ }
467
+ const $clip = $("<div>")
468
+ .addClass("clip").addClass(clip.type)
469
+ .attr("data-id", clip.id)
470
+ .css({ left: `${clip.x}px`, width: `${clip.width}px` })
471
+ .html(clipHTML);
472
+ if (state.selectedClipIds.has(clip.id)) {
473
+ $clip.addClass("selected");
474
+ }
475
+ makeClipDraggable($clip);
476
+ makeClipResizable($clip);
477
+ $row.append($clip);
478
+ });
479
+ }
480
+
481
+ function refreshClipSelectionStyles() {
482
+ $(".clip").each(function () {
483
+ const id = $(this).attr("data-id");
484
+ $(this).toggleClass("selected", state.selectedClipIds.has(id));
485
+ });
486
+ }
487
+
488
+ function selectOnlyClip(id) {
489
+ state.selectedClipIds = new Set([id]);
490
+ refreshClipSelectionStyles();
491
+ }
492
+
493
+ function toggleClipSelection(id) {
494
+ if (state.selectedClipIds.has(id)) {
495
+ state.selectedClipIds.delete(id);
496
+ } else {
497
+ state.selectedClipIds.add(id);
498
+ }
499
+ refreshClipSelectionStyles();
500
+ }
501
+
502
+ function clearClipSelection() {
503
+ if (state.selectedClipIds.size === 0) {
504
+ return;
505
+ }
506
+ state.selectedClipIds.clear();
507
+ refreshClipSelectionStyles();
508
+ }
509
+
510
+ function deleteSelectedClips() {
511
+ if (state.selectedClipIds.size === 0) {
512
+ return;
513
+ }
514
+ const ids = [...state.selectedClipIds];
515
+ apiPost("/api/clip/delete", { ids })
516
+ .then((payload) => {
517
+ if (payload.error) {
518
+ throw new Error(payload.error);
519
+ }
520
+ state.selectedClipIds.clear();
521
+ applyServerState(payload.state);
522
+ renderTimeline();
523
+ updatePreview();
524
+ })
525
+ .catch((err) => {
526
+ showStatus(`Delete clip failed: ${err.message || err}`);
527
+ });
528
+ }
529
+
530
+ function calcAllowedGroupDelta(ids, delta) {
531
+ let allowed = delta;
532
+ ids.forEach((id) => {
533
+ const clip = state.clips.find((c) => c.id === id);
534
+ if (!clip) {
535
+ return;
536
+ }
537
+ allowed = Math.max(allowed, -clip.x);
538
+ allowed = Math.min(allowed, state.trackWidth - (clip.x + clip.width));
539
+ });
540
+ return allowed;
541
+ }
542
+
543
+ function renderTimeline() {
544
+ const $timeline = $("#timeline");
545
+ const $controls = $("#trackControls");
546
+ $('.track-row').remove();
547
+ $('.add-track').remove();
548
+ $controls.empty();
549
+
550
+ for (let i = 0; i < state.rowCount; i += 1) {
551
+ $timeline.append(makeTrack(i));
552
+ $controls.append(makeTrackControl(i));
553
+ }
554
+
555
+ $timeline.append(makeAddTrackBar());
556
+ $controls.append($("<div>").addClass("track-controls-spacer"));
557
+ renderClips();
558
+ refreshPinGeometry();
559
+ renderFrameIndicators();
560
+ setPreviewHeight(state.previewHeight);
561
+ }
562
+
563
+ function addTrack() {
564
+ state.rowCount += 1;
565
+ renderTimeline();
566
+ apiPost("/api/timeline/rows", { rowCount: state.rowCount })
567
+ .then((res) => {
568
+ if (res.error) {
569
+ throw new Error(res.error);
570
+ }
571
+ applyServerState(res.state);
572
+ renderTimeline();
573
+ })
574
+ .catch((err) => {
575
+ showStatus(`Add track failed: ${err.message || err}`);
576
+ });
577
+ }
578
+
579
+ function deleteTrack(rowIndex) {
580
+ apiPost("/api/timeline/delete-row", { row: rowIndex })
581
+ .then((res) => {
582
+ if (res.error) {
583
+ throw new Error(res.error);
584
+ }
585
+ applyServerState(res.state);
586
+ renderTimeline();
587
+ updatePreview();
588
+ })
589
+ .catch((err) => {
590
+ showStatus(`Delete track failed: ${err.message || err}`);
591
+ });
592
+ }
593
+
594
+ function placeClip(rowIndex, x, width, type) {
595
+ if (!state.hasPlotObjects) {
596
+ showStatus("Cannot create clip: no plot objects provided to GUI_web.GUI(...)");
597
+ return;
598
+ }
599
+
600
+ apiPost("/api/clip/create", {
601
+ type,
602
+ row: rowIndex,
603
+ x,
604
+ width,
605
+ })
606
+ .then((payload) => {
607
+ if (payload.error) {
608
+ throw new Error(payload.error);
609
+ }
610
+ applyServerState(payload.state);
611
+ renderTimeline();
612
+ if (payload.clipId) {
613
+ openClipDialog(payload.clipId);
614
+ }
615
+ updatePreview();
616
+ })
617
+ .catch((err) => {
618
+ showStatus(`Create clip failed: ${err.message || err}`);
619
+ });
620
+ }
621
+
622
+ function overlapsInRow(rowIndex, x, width, ignoreId) {
623
+ const ignoreSet = new Set();
624
+ if (Array.isArray(ignoreId)) {
625
+ ignoreId.forEach((value) => ignoreSet.add(String(value)));
626
+ } else if (ignoreId !== undefined && ignoreId !== null) {
627
+ ignoreSet.add(String(ignoreId));
628
+ }
629
+
630
+ const left = x;
631
+ const right = x + width;
632
+ return state.clips.some((clip) => {
633
+ if (clip.row !== rowIndex) {
634
+ return false;
635
+ }
636
+ if (ignoreSet.has(String(clip.id))) {
637
+ return false;
638
+ }
639
+ const cLeft = clip.x;
640
+ const cRight = clip.x + clip.width;
641
+ return left < cRight && right > cLeft;
642
+ });
643
+ }
644
+
645
+ function rowFromClientY(clientY) {
646
+ const timelineEl = $("#timeline")[0];
647
+ if (!timelineEl) {
648
+ return 0;
649
+ }
650
+
651
+ const timelineRect = timelineEl.getBoundingClientRect();
652
+ const rowSpan = state.trackHeight + 2;
653
+ const rawRow = Math.floor((clientY - timelineRect.top) / rowSpan);
654
+ return Math.max(0, Math.min(Math.max(0, state.rowCount - 1), rawRow));
655
+ }
656
+
657
+ function blockingClipsInRow(rowIndex, x, width, ignoreId) {
658
+ const ignoreSet = new Set();
659
+ if (Array.isArray(ignoreId)) {
660
+ ignoreId.forEach((value) => ignoreSet.add(String(value)));
661
+ } else if (ignoreId !== undefined && ignoreId !== null) {
662
+ ignoreSet.add(String(ignoreId));
663
+ }
664
+
665
+ const left = x;
666
+ const right = x + width;
667
+ return state.clips.filter((clip) => {
668
+ if (clip.row !== rowIndex) {
669
+ return false;
670
+ }
671
+ if (ignoreSet.has(String(clip.id))) {
672
+ return false;
673
+ }
674
+ const cLeft = clip.x;
675
+ const cRight = clip.x + clip.width;
676
+ return left < cRight && right > cLeft;
677
+ });
678
+ }
679
+
680
+ function resolveDropLeft(rowIndex, width, desiredLeft, ignoreId, alreadyResolvedMoves) {
681
+ const minLeft = 0;
682
+ const maxLeft = Math.max(0, state.trackWidth - width);
683
+ const clampedDesired = Math.max(minLeft, Math.min(maxLeft, Math.round(desiredLeft)));
684
+
685
+ const occupiedRanges = [];
686
+ if (alreadyResolvedMoves) {
687
+ alreadyResolvedMoves.forEach((move) => {
688
+ if (move.nextRow === rowIndex) {
689
+ occupiedRanges.push({ left: move.nextLeft, right: move.nextLeft + move.clip.width });
690
+ }
691
+ });
692
+ }
693
+
694
+ function isBlockedByResolved(left, right) {
695
+ return occupiedRanges.some((range) => left < range.right && right > range.left);
696
+ }
697
+
698
+ if (!overlapsInRow(rowIndex, clampedDesired, width, ignoreId) && !isBlockedByResolved(clampedDesired, clampedDesired + width)) {
699
+ return clampedDesired;
700
+ }
701
+
702
+ const candidates = [];
703
+ blockingClipsInRow(rowIndex, clampedDesired, width, ignoreId).forEach((clip) => {
704
+ const leftSide = clip.x - width;
705
+ const rightSide = clip.x + clip.width;
706
+ if (leftSide >= minLeft && !overlapsInRow(rowIndex, leftSide, width, ignoreId) && !isBlockedByResolved(leftSide, leftSide + width)) {
707
+ candidates.push(leftSide);
708
+ }
709
+ if (rightSide <= maxLeft && !overlapsInRow(rowIndex, rightSide, width, ignoreId) && !isBlockedByResolved(rightSide, rightSide + width)) {
710
+ candidates.push(rightSide);
711
+ }
712
+ });
713
+
714
+ if (candidates.length === 0) {
715
+ return null;
716
+ }
717
+
718
+ candidates.sort((a, b) => {
719
+ const da = Math.abs(a - clampedDesired);
720
+ const db = Math.abs(b - clampedDesired);
721
+ if (da !== db) {
722
+ return da - db;
723
+ }
724
+ return a - b;
725
+ });
726
+
727
+ return candidates[0];
728
+ }
729
+
730
+ function findAvailableRow(x, width) {
731
+ for (let row = 0; row < state.rowCount; row += 1) {
732
+ if (!overlapsInRow(row, x, width)) {
733
+ return row;
734
+ }
735
+ }
736
+ addTrack();
737
+ return state.rowCount - 1;
738
+ }
739
+
740
+ function makeClipDraggable($clip) {
741
+ const id = $clip.attr("data-id");
742
+ let dragging = false;
743
+ let moved = false;
744
+ let startX = 0;
745
+ let startRow = 0;
746
+ let dragIds = [];
747
+ let originLeftById = {};
748
+ let originRowById = {};
749
+ let originClipStateById = {};
750
+ const resizeHandlePx = 6;
751
+
752
+ function applyGroupPreview(deltaX, rowDelta) {
753
+ dragIds.forEach((dragId) => {
754
+ const left = originLeftById[dragId] + deltaX;
755
+ const originRow = originRowById[dragId] ?? 0;
756
+ const previewRow = Math.max(0, Math.min(Math.max(0, state.rowCount - 1), originRow + rowDelta));
757
+ const $clipEl = $(`.clip[data-id='${dragId}']`);
758
+ const $row = $(`.track-row[data-row='${previewRow}']`);
759
+ if ($clipEl.length > 0 && $row.length > 0 && $clipEl.parent()[0] !== $row[0]) {
760
+ $row.append($clipEl);
761
+ }
762
+ $clipEl.css("left", `${left}px`);
763
+ });
764
+ }
765
+
766
+ $clip.on("mousedown", function (event) {
767
+ const clipLeft = $clip.offset().left;
768
+ const localX = event.pageX - clipLeft;
769
+ const clipWidth = $clip.outerWidth();
770
+ if (localX <= resizeHandlePx || localX >= clipWidth - resizeHandlePx) {
771
+ return;
772
+ }
773
+
774
+ const additive = event.shiftKey || event.metaKey || event.ctrlKey;
775
+ if (additive) {
776
+ toggleClipSelection(id);
777
+ } else if (!state.selectedClipIds.has(id)) {
778
+ selectOnlyClip(id);
779
+ }
780
+
781
+ dragIds = state.selectedClipIds.has(id) ? [...state.selectedClipIds] : [id];
782
+ if (dragIds.length === 0) {
783
+ dragIds = [id];
784
+ }
785
+
786
+ originLeftById = {};
787
+ originRowById = {};
788
+ originClipStateById = {};
789
+ dragIds.forEach((dragId) => {
790
+ const dragClip = state.clips.find((c) => c.id === dragId);
791
+ if (!dragClip) {
792
+ return;
793
+ }
794
+ originLeftById[dragId] = dragClip.x;
795
+ originRowById[dragId] = dragClip.row;
796
+ originClipStateById[dragId] = { ...dragClip };
797
+ });
798
+
799
+ dragging = true;
800
+ moved = false;
801
+ startX = event.pageX;
802
+ startRow = rowFromClientY(event.clientY);
803
+ state.suppressClipClickUntil = Date.now() + 150;
804
+ event.preventDefault();
805
+ });
806
+
807
+ $(document).on(`mousemove.clipDrag.${id}`, function (event) {
808
+ if (!dragging) {
809
+ return;
810
+ }
811
+ const rawDelta = Math.round(event.pageX - startX);
812
+ if (Math.abs(rawDelta) >= 2) {
813
+ moved = true;
814
+ }
815
+ const delta = calcAllowedGroupDelta(dragIds, rawDelta);
816
+ const currentRow = rowFromClientY(event.clientY);
817
+ applyGroupPreview(delta, currentRow - startRow);
818
+ });
819
+
820
+ $(document).on(`mouseup.clipDrag.${id}`, function (event) {
821
+ if (!dragging) {
822
+ return;
823
+ }
824
+ dragging = false;
825
+
826
+ if (moved) {
827
+ state.suppressClipClickUntil = Date.now() + 150;
828
+ }
829
+
830
+ const anchorLeft = parseFloat($(`.clip[data-id='${id}']`).css("left"));
831
+ const anchorOrigin = originLeftById[id] ?? anchorLeft;
832
+ const snappedAnchorLeft = Math.round(anchorLeft / 10) * 10;
833
+ const snappedDelta = snappedAnchorLeft - anchorOrigin;
834
+ const finalDelta = calcAllowedGroupDelta(dragIds, snappedDelta);
835
+ const finalRowDelta = rowFromClientY(event.clientY) - startRow;
836
+
837
+ const idSet = new Set(dragIds);
838
+ const resolvedMoves = [];
839
+ let valid = true;
840
+ dragIds.forEach((dragId) => {
841
+ const clip = originClipStateById[dragId];
842
+ if (!clip) {
843
+ return;
844
+ }
845
+ const nextRow = Math.max(0, (originRowById[dragId] ?? clip.row) + finalRowDelta);
846
+ const desiredLeft = clip.x + finalDelta;
847
+ const resolvedLeft = resolveDropLeft(nextRow, clip.width, desiredLeft, dragId, resolvedMoves);
848
+ if (resolvedLeft === null) {
849
+ valid = false;
850
+ return;
851
+ }
852
+ resolvedMoves.push({ clip, nextLeft: resolvedLeft, nextRow });
853
+ });
854
+
855
+ if (!valid) {
856
+ renderTimeline();
857
+ return;
858
+ }
859
+
860
+ const updates = resolvedMoves.map(({ clip, nextLeft, nextRow }) => {
861
+ return apiPost("/api/clip/update", {
862
+ id: clip.id,
863
+ x: nextLeft,
864
+ width: clip.width,
865
+ row: nextRow,
866
+ });
867
+ });
868
+
869
+ Promise.all(updates)
870
+ .then((results) => {
871
+ const last = results[results.length - 1];
872
+ if (!last || last.error) {
873
+ throw new Error(last?.error || "Update failed");
874
+ }
875
+ applyServerState(last.state);
876
+ renderTimeline();
877
+ updatePreview();
878
+ })
879
+ .catch((err) => {
880
+ showStatus(`Update clip failed: ${err.message || err}`);
881
+ renderTimeline();
882
+ });
883
+ });
884
+ }
885
+
886
+ function makeClipResizable($clip) {
887
+ const id = $clip.attr("data-id");
888
+ let resizing = false;
889
+ let resized = false;
890
+ let side = null;
891
+ let startX = 0;
892
+ let startLeft = 0;
893
+ let startWidth = 0;
894
+
895
+ $clip.on("mousemove", function (event) {
896
+ if (resizing) {
897
+ return;
898
+ }
899
+ const x = event.offsetX;
900
+ const w = $clip.outerWidth();
901
+ $clip.removeClass("resize-left resize-right");
902
+ if (x <= 6) {
903
+ $clip.addClass("resize-left");
904
+ } else if (x >= w - 6) {
905
+ $clip.addClass("resize-right");
906
+ }
907
+ });
908
+
909
+ $clip.on("mousedown", function (event) {
910
+ const x = event.offsetX;
911
+ const w = $clip.outerWidth();
912
+ if (!(x <= 6 || x >= w - 6)) {
913
+ return;
914
+ }
915
+ resizing = true;
916
+ resized = false;
917
+ side = x <= 6 ? "left" : "right";
918
+ startX = event.pageX;
919
+ startLeft = parseFloat($clip.css("left"));
920
+ startWidth = $clip.outerWidth();
921
+ event.stopPropagation();
922
+ event.preventDefault();
923
+ });
924
+
925
+ $(document).on(`mousemove.clipResize.${id}`, function (event) {
926
+ if (!resizing) {
927
+ return;
928
+ }
929
+ const delta = event.pageX - startX;
930
+ if (Math.abs(delta) >= 2) {
931
+ resized = true;
932
+ }
933
+ let nextLeft = startLeft;
934
+ let nextWidth = startWidth;
935
+
936
+ if (side === "left") {
937
+ nextLeft = Math.max(0, Math.min(startLeft + delta, startLeft + startWidth - 1));
938
+ nextWidth = Math.max(1, startWidth - (nextLeft - startLeft));
939
+ } else {
940
+ nextWidth = Math.max(1, Math.min(startWidth + delta, state.trackWidth - startLeft));
941
+ }
942
+
943
+ $clip.css({ left: `${Math.round(nextLeft)}px`, width: `${Math.round(nextWidth)}px` });
944
+ });
945
+
946
+ $(document).on(`mouseup.clipResize.${id}`, function () {
947
+ if (!resizing) {
948
+ return;
949
+ }
950
+ resizing = false;
951
+ if (resized) {
952
+ state.suppressClipClickUntil = Date.now() + 150;
953
+ }
954
+ const clip = state.clips.find((c) => c.id === id);
955
+ if (!clip) {
956
+ return;
957
+ }
958
+
959
+ const nextLeft = Math.max(0, Math.round(parseFloat($clip.css("left"))));
960
+ const nextWidth = Math.max(1, Math.round($clip.outerWidth()));
961
+
962
+ if (!overlapsInRow(clip.row, nextLeft, nextWidth, clip.id)) {
963
+ apiPost("/api/clip/update", {
964
+ id: clip.id,
965
+ x: nextLeft,
966
+ width: nextWidth,
967
+ row: clip.row,
968
+ })
969
+ .then((payload) => {
970
+ if (payload.error) {
971
+ throw new Error(payload.error);
972
+ }
973
+ const invalidateFrom = Math.min(clip.x, nextLeft);
974
+ state.renderedFrames = new Set([...state.renderedFrames].filter((f) => f < invalidateFrom));
975
+ applyServerState(payload.state);
976
+ renderTimeline();
977
+ updatePreview();
978
+ })
979
+ .catch((err) => {
980
+ showStatus(`Resize failed: ${err.message || err}`);
981
+ renderTimeline();
982
+ });
983
+ } else {
984
+ renderTimeline();
985
+ }
986
+ });
987
+ }
988
+
989
+ function openClipDialog(clipId) {
990
+ apiPost("/api/clip/get", { id: clipId })
991
+ .then((payload) => {
992
+ if (payload.error) {
993
+ throw new Error(payload.error);
994
+ }
995
+ const p = payload.properties;
996
+ state.activeDialogClipId = clipId;
997
+ $("#dlgDelay").val(p.delay ?? 0);
998
+ $("#dlgDuration").val(p.duration ?? 1);
999
+ $("#dlgPersistent").prop("checked", Boolean(p.persistent));
1000
+
1001
+ const $e = $("#dlgEasing");
1002
+ $e.empty();
1003
+ (p.availableEasings || []).forEach((name) => {
1004
+ const opt = $("<option>").attr("value", name).text(name);
1005
+ if (name === p.easing) {
1006
+ opt.attr("selected", "selected");
1007
+ }
1008
+ $e.append(opt);
1009
+ });
1010
+
1011
+ const $obj = $("#dlgObject");
1012
+ $obj.empty();
1013
+ (p.plotObjectOptions || []).forEach((opt) => {
1014
+ const $opt = $("<option>").attr("value", String(opt.id)).text(opt.name);
1015
+ if (Number(opt.id) === Number(p.plotObjectId)) {
1016
+ $opt.attr("selected", "selected");
1017
+ }
1018
+ $obj.append($opt);
1019
+ });
1020
+
1021
+ const typeKey = normalizeClipTypeKey(p.typeKey, p.type);
1022
+ applyDialogSchema(typeKey);
1023
+ fillDialogFields(p);
1024
+
1025
+ $("#clipDialogBackdrop").removeClass("hidden");
1026
+ })
1027
+ .catch((err) => {
1028
+ showStatus(`Open properties failed: ${err.message || err}`);
1029
+ });
1030
+ }
1031
+
1032
+ function closeClipDialog() {
1033
+ state.activeDialogClipId = null;
1034
+ $("#clipDialogBackdrop").addClass("hidden");
1035
+ }
1036
+
1037
+ function saveClipDialog() {
1038
+ if (!state.activeDialogClipId) {
1039
+ return;
1040
+ }
1041
+ const payload = {
1042
+ id: state.activeDialogClipId,
1043
+ delay: Number($("#dlgDelay").val() || 0),
1044
+ duration: Number($("#dlgDuration").val() || 1),
1045
+ easing: $("#dlgEasing").val(),
1046
+ persistent: $("#dlgPersistent").is(":checked"),
1047
+ plotObjectId: Number($("#dlgObject").val() || 0),
1048
+ ...buildDialogFieldPayload(),
1049
+ };
1050
+
1051
+ apiPost("/api/clip/properties", payload)
1052
+ .then((res) => {
1053
+ if (res.error) {
1054
+ throw new Error(res.error);
1055
+ }
1056
+ applyServerState(res.state);
1057
+ renderTimeline();
1058
+ updatePreview();
1059
+ closeClipDialog();
1060
+ })
1061
+ .catch((err) => {
1062
+ showStatus(`Save properties failed: ${err.message || err}`);
1063
+ });
1064
+ }
1065
+
1066
+ function setPinFromClientX(clientX) {
1067
+ const stripRect = $("#frameStrip")[0].getBoundingClientRect();
1068
+ const x = Math.round(clientX - stripRect.left);
1069
+ setPinFrame(x, true);
1070
+ }
1071
+
1072
+ function playStep() {
1073
+ if (!state.isPlaying) {
1074
+ return;
1075
+ }
1076
+ if (state.playWaitingForFrame) {
1077
+ return;
1078
+ }
1079
+
1080
+ const currentFrame = Math.round(state.pinX);
1081
+ if (currentFrame >= state.trackWidth) {
1082
+ if (state.saveVideoPending) {
1083
+ finishSaveVideo();
1084
+ return;
1085
+ }
1086
+ if (state.loopEnabled) {
1087
+ state.playWaitingForFrame = true;
1088
+ setPinFrame(0, true);
1089
+ return;
1090
+ }
1091
+ stopPlayback();
1092
+ return;
1093
+ }
1094
+
1095
+ const next = Math.min(state.trackWidth, currentFrame + 1);
1096
+ state.playWaitingForFrame = true;
1097
+ setPinFrame(next, true);
1098
+ }
1099
+
1100
+ function finishSaveVideo() {
1101
+ if (state.saveVideoInProgress) {
1102
+ return;
1103
+ }
1104
+ state.saveVideoInProgress = true;
1105
+ setSaveButtonState();
1106
+ stopPlayback();
1107
+ showStatus("Preparing video file...");
1108
+
1109
+ apiPost("/api/save-video-temp", { timelineWidth: state.trackWidth })
1110
+ .then((res) => {
1111
+ if (res.error) {
1112
+ throw new Error(res.error);
1113
+ }
1114
+ const token = String(res.token || "");
1115
+ const serverFilename = String(res.filename || `${state.timelineName || "video"}.mp4`);
1116
+ if (!token) {
1117
+ throw new Error("Missing export token.");
1118
+ }
1119
+
1120
+ let targetName = state.saveVideoFileName || serverFilename;
1121
+ if (!targetName.includes(".") && serverFilename.includes(".")) {
1122
+ targetName += `.${serverFilename.split(".").pop()}`;
1123
+ }
1124
+
1125
+ return fetch(`/api/save-video-file?token=${encodeURIComponent(token)}`)
1126
+ .then((r) => {
1127
+ if (!r.ok) {
1128
+ throw new Error(`Download failed (${r.status})`);
1129
+ }
1130
+ return r.blob();
1131
+ })
1132
+ .then((blob) => {
1133
+ downloadBlob(blob, targetName);
1134
+ return targetName;
1135
+ });
1136
+ })
1137
+ .then((savedAs) => {
1138
+ showStatus(`Saved video: ${savedAs}`);
1139
+ })
1140
+ .catch((err) => {
1141
+ showStatus(`Save video failed: ${err.message || err}`);
1142
+ })
1143
+ .finally(() => {
1144
+ state.saveVideoPending = false;
1145
+ state.saveVideoInProgress = false;
1146
+ state.saveVideoFileName = null;
1147
+ setSaveButtonState();
1148
+ });
1149
+ }
1150
+
1151
+ function downloadBlob(blob, filename) {
1152
+ const url = URL.createObjectURL(blob);
1153
+ const a = document.createElement("a");
1154
+ a.href = url;
1155
+ a.download = filename;
1156
+ document.body.appendChild(a);
1157
+ a.click();
1158
+ document.body.removeChild(a);
1159
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
1160
+ }
1161
+
1162
+ function clearRenderedFrames() {
1163
+ stopPlayback();
1164
+ state.renderedFrames = new Set();
1165
+ state.pinX = 0;
1166
+ $("#previewImage").attr("src", "src/logo_bg.png");
1167
+ loadState().then(() => {
1168
+ renderTimeline();
1169
+ });
1170
+ }
1171
+
1172
+ function restartPlayTimer() {
1173
+ if (state.playTimer) {
1174
+ clearInterval(state.playTimer);
1175
+ state.playTimer = null;
1176
+ }
1177
+ const fps = 30 * 3 * currentPlaybackSpeed();
1178
+ state.playTimer = setInterval(playStep, 1000 / fps);
1179
+ }
1180
+
1181
+ function startPlayback() {
1182
+ if (state.isPlaying) {
1183
+ return;
1184
+ }
1185
+ if (Math.round(state.pinX) >= state.trackWidth) {
1186
+ setPinFrame(0, true);
1187
+ }
1188
+ state.isPlaying = true;
1189
+ state.playWaitingForFrame = false;
1190
+ setPlayButtonState();
1191
+ restartPlayTimer();
1192
+ }
1193
+
1194
+ function stopPlayback() {
1195
+ state.isPlaying = false;
1196
+ state.playWaitingForFrame = false;
1197
+ setPlayButtonState();
1198
+ if (state.playTimer) {
1199
+ clearInterval(state.playTimer);
1200
+ state.playTimer = null;
1201
+ }
1202
+ }
1203
+
1204
+ function loadDemoProject() {
1205
+ loadState().then(() => {
1206
+ showStatus("Loaded timeline state from Python objects.");
1207
+ updatePreview();
1208
+ });
1209
+ }
1210
+
1211
+ function paletteInternalType($item) {
1212
+ const dataType = String($item.attr("data-type") || "").trim();
1213
+ if (dataType) {
1214
+ return dataType;
1215
+ }
1216
+ return String($item.text() || "Clip").trim();
1217
+ }
1218
+
1219
+ function enablePaletteDragDrop() {
1220
+ $(document).on("dragstart", ".palette-item", function (event) {
1221
+ const type = paletteInternalType($(this));
1222
+ event.originalEvent.dataTransfer.setData("text/plain", type);
1223
+ event.originalEvent.dataTransfer.effectAllowed = "copy";
1224
+ });
1225
+
1226
+ $(document).on("dragover", ".track-row", function (event) {
1227
+ event.preventDefault();
1228
+ event.originalEvent.dataTransfer.dropEffect = "copy";
1229
+ });
1230
+
1231
+ $(document).on("drop", ".track-row", function (event) {
1232
+ event.preventDefault();
1233
+ const type = event.originalEvent.dataTransfer.getData("text/plain") || "Clip";
1234
+ const row = Number($(this).attr("data-row"));
1235
+ const rowRect = this.getBoundingClientRect();
1236
+ const width = 120;
1237
+ const x = Math.max(0, Math.min(Math.round(event.originalEvent.clientX - rowRect.left - width / 2), state.trackWidth - width));
1238
+
1239
+ if (overlapsInRow(row, x, width)) {
1240
+ const fallbackRow = findAvailableRow(x, width);
1241
+ placeClip(fallbackRow, x, width, type);
1242
+ } else {
1243
+ placeClip(row, x, width, type);
1244
+ }
1245
+ });
1246
+ }
1247
+
1248
+ function bindEvents() {
1249
+ enablePaletteDragDrop();
1250
+
1251
+ let previewResizing = false;
1252
+ let previewResizeStartY = 0;
1253
+ let previewResizeStartHeight = state.previewHeight;
1254
+
1255
+ $("#previewResizeHandle").on("mousedown", function (event) {
1256
+ previewResizing = true;
1257
+ previewResizeStartY = event.pageY;
1258
+ previewResizeStartHeight = state.previewHeight;
1259
+ event.preventDefault();
1260
+ });
1261
+
1262
+ $("#pinHandle").on("mousedown", function (event) {
1263
+ state.pinDragging = true;
1264
+ event.preventDefault();
1265
+ });
1266
+
1267
+ $(document).on("mousemove", function (event) {
1268
+ if (previewResizing) {
1269
+ const deltaY = event.pageY - previewResizeStartY;
1270
+ setPreviewHeight(previewResizeStartHeight + deltaY);
1271
+ }
1272
+ if (!state.pinDragging) {
1273
+ return;
1274
+ }
1275
+ stopPlayback();
1276
+ setPinFromClientX(event.clientX);
1277
+ });
1278
+
1279
+ $(document).on("mouseup", function () {
1280
+ previewResizing = false;
1281
+ if (state.pinDragging) {
1282
+ markFrameRendered(state.pinX);
1283
+ }
1284
+ state.pinDragging = false;
1285
+ });
1286
+
1287
+ $("#frameStrip").on("click", function (event) {
1288
+ stopPlayback();
1289
+ setPinFromClientX(event.clientX);
1290
+ markFrameRendered(state.pinX);
1291
+ });
1292
+
1293
+ $("#btnToStart").on("click", function () {
1294
+ stopPlayback();
1295
+ setPinFrame(0, true);
1296
+ markFrameRendered(state.pinX);
1297
+ });
1298
+
1299
+ $("#btnPrev").on("click", function () {
1300
+ stopPlayback();
1301
+ setPinFrame(Math.round(state.pinX) - 1, true);
1302
+ markFrameRendered(state.pinX);
1303
+ });
1304
+
1305
+ $("#btnPlayPause").on("click", function () {
1306
+ if (state.isPlaying) {
1307
+ stopPlayback();
1308
+ } else {
1309
+ startPlayback();
1310
+ }
1311
+ });
1312
+
1313
+ $("#btnLoop").on("click", function () {
1314
+ state.loopEnabled = !state.loopEnabled;
1315
+ setLoopButtonState();
1316
+ });
1317
+
1318
+ $("#btnNext").on("click", function () {
1319
+ stopPlayback();
1320
+ setPinFrame(Math.round(state.pinX) + 1, true);
1321
+ markFrameRendered(state.pinX);
1322
+ });
1323
+
1324
+ $("#btnToEnd").on("click", function () {
1325
+ stopPlayback();
1326
+ setPinFrame(state.trackWidth, true);
1327
+ markFrameRendered(state.pinX);
1328
+ });
1329
+
1330
+ $("#btnSpeed").on("click", function () {
1331
+ state.playbackSpeedIndex = (state.playbackSpeedIndex + 1) % state.playbackSpeedOptions.length;
1332
+ setSpeedButtonState();
1333
+ if (state.isPlaying) {
1334
+ restartPlayTimer();
1335
+ }
1336
+ });
1337
+
1338
+ $("#btnSaveVideo").on("click", function () {
1339
+ if (state.saveVideoPending || state.saveVideoInProgress) {
1340
+ stopSaveVideoMode();
1341
+ return;
1342
+ }
1343
+
1344
+ if (!state.hasTimeline || !state.hasPlotObjects) {
1345
+ showStatus("Save unavailable: pass a Timeline and plot_objects to GUI_web.GUI(...)");
1346
+ return;
1347
+ }
1348
+
1349
+ const suggested = `${state.timelineName || "video"}.mp4`;
1350
+ const input = window.prompt("Enter output file name:", suggested);
1351
+ if (!input || !String(input).trim()) {
1352
+ showStatus("Save canceled.");
1353
+ return;
1354
+ }
1355
+
1356
+ state.saveVideoFileName = String(input).trim();
1357
+ state.saveVideoPending = true;
1358
+ setSaveButtonState();
1359
+ stopPlayback();
1360
+ setPinFrame(0, true);
1361
+ showStatus("Rendering timeline before save...");
1362
+ startPlayback();
1363
+ });
1364
+
1365
+ $("#btnSaveProject").on("click", function () {
1366
+ if (!state.hasTimeline) {
1367
+ showStatus("Save project unavailable: pass a Timeline to GUI_web.GUI(...)");
1368
+ return;
1369
+ }
1370
+
1371
+ const suggested = `${state.timelineName || "project"}.dpl`;
1372
+ const input = window.prompt("Enter project file name:", suggested);
1373
+ if (!input || !String(input).trim()) {
1374
+ showStatus("Save project canceled.");
1375
+ return;
1376
+ }
1377
+
1378
+ apiPost("/api/save-project", { filename: String(input).trim() })
1379
+ .then((res) => {
1380
+ if (res.error) {
1381
+ throw new Error(res.error);
1382
+ }
1383
+ const savedPath = String(res.filename || input.trim());
1384
+ const downloadName = savedPath.split(/[\\/]/).pop() || input.trim();
1385
+
1386
+ return fetch(`/api/save-project-file?filename=${encodeURIComponent(savedPath)}`)
1387
+ .then((r) => {
1388
+ if (!r.ok) {
1389
+ throw new Error(`Download failed (${r.status})`);
1390
+ }
1391
+ return r.blob();
1392
+ })
1393
+ .then((blob) => {
1394
+ downloadBlob(blob, downloadName);
1395
+ showStatus(`Saved project: ${downloadName}`);
1396
+ });
1397
+ })
1398
+ .catch((err) => {
1399
+ showStatus(`Save project failed: ${err.message || err}`);
1400
+ });
1401
+ });
1402
+
1403
+ $("#btnClearFrames").on("click", function () {
1404
+ clearRenderedFrames();
1405
+ });
1406
+
1407
+ $(document).on("click", ".add-track", function () {
1408
+ addTrack();
1409
+ });
1410
+
1411
+ $(document).on("click", ".delete-track", function () {
1412
+ const row = Number($(this).attr("data-row"));
1413
+ deleteTrack(row);
1414
+ });
1415
+
1416
+ $(document).on("click", ".clip", function (event) {
1417
+ if (Date.now() < state.suppressClipClickUntil) {
1418
+ event.stopPropagation();
1419
+ return;
1420
+ }
1421
+ const id = $(this).attr("data-id");
1422
+ const additive = event.shiftKey || event.metaKey || event.ctrlKey;
1423
+ if (additive) {
1424
+ toggleClipSelection(id);
1425
+ } else {
1426
+ selectOnlyClip(id);
1427
+ }
1428
+ event.stopPropagation();
1429
+ });
1430
+
1431
+ $(document).on("click", ".track-row, #timeline, #timelineViewport", function (event) {
1432
+ if ($(event.target).closest(".clip").length === 0) {
1433
+ clearClipSelection();
1434
+ }
1435
+ });
1436
+
1437
+ $(document).on("keydown", function (event) {
1438
+ const dialogOpen = !$("#clipDialogBackdrop").hasClass("hidden") && Boolean(state.activeDialogClipId);
1439
+ if (dialogOpen) {
1440
+ if (event.key === "Escape") {
1441
+ event.preventDefault();
1442
+ closeClipDialog();
1443
+ return;
1444
+ }
1445
+ if (event.key === "Enter") {
1446
+ event.preventDefault();
1447
+ saveClipDialog();
1448
+ return;
1449
+ }
1450
+ }
1451
+
1452
+ if (event.target && ["INPUT", "TEXTAREA", "SELECT"].includes(event.target.tagName)) {
1453
+ return;
1454
+ }
1455
+ if (event.key === "Delete" || event.key === "Backspace") {
1456
+ if (state.selectedClipIds.size > 0) {
1457
+ event.preventDefault();
1458
+ deleteSelectedClips();
1459
+ }
1460
+ }
1461
+ });
1462
+
1463
+ $(document).on("dblclick", ".clip", function () {
1464
+ const id = $(this).attr("data-id");
1465
+ openClipDialog(id);
1466
+ });
1467
+
1468
+ let timelineResizing = false;
1469
+ let timelineStartX = 0;
1470
+ let timelineStartWidth = 0;
1471
+ $("#timelineResizeHandle").on("mousedown", function (event) {
1472
+ timelineResizing = true;
1473
+ timelineStartX = event.pageX;
1474
+ timelineStartWidth = state.trackWidth;
1475
+ event.preventDefault();
1476
+ });
1477
+
1478
+ $(document).on("mousemove", function (event) {
1479
+ if (!timelineResizing) {
1480
+ return;
1481
+ }
1482
+ const dx = event.pageX - timelineStartX;
1483
+ const w = Math.max(100, Math.round(timelineStartWidth + dx));
1484
+ state.trackWidth = w;
1485
+ renderTimeline();
1486
+ });
1487
+
1488
+ $(document).on("mouseup", function () {
1489
+ if (!timelineResizing) {
1490
+ return;
1491
+ }
1492
+ timelineResizing = false;
1493
+ apiPost("/api/timeline/width", { width: state.trackWidth, rowCount: state.rowCount })
1494
+ .then((res) => {
1495
+ if (res.error) {
1496
+ throw new Error(res.error);
1497
+ }
1498
+ applyServerState(res.state);
1499
+ renderTimeline();
1500
+ })
1501
+ .catch((err) => {
1502
+ showStatus(`Timeline resize failed: ${err.message || err}`);
1503
+ });
1504
+ });
1505
+
1506
+ $("#dlgCancel").on("click", function () {
1507
+ closeClipDialog();
1508
+ });
1509
+
1510
+ $("#dlgSave").on("click", function () {
1511
+ saveClipDialog();
1512
+ });
1513
+
1514
+ $("#clipDialogBackdrop").on("click", function (event) {
1515
+ if (event.target === this) {
1516
+ closeClipDialog();
1517
+ }
1518
+ });
1519
+
1520
+ $("#clearAll").on("click", function () {
1521
+ clearRenderedFrames();
1522
+ });
1523
+
1524
+ $("#loadDemo").on("click", function () {
1525
+ loadDemoProject();
1526
+ });
1527
+
1528
+ $(window).on("resize", function () {
1529
+ setPreviewHeight(state.previewHeight);
1530
+ renderTimelineTicks();
1531
+ });
1532
+
1533
+ $("#previewImage").on("load", function () {
1534
+ const requestId = Number($(this).attr("data-request-id") || 0);
1535
+ const frame = Number($(this).attr("data-frame") || 0);
1536
+ if (requestId !== state.pendingPreviewRequestId) {
1537
+ return;
1538
+ }
1539
+ markFrameRendered(frame);
1540
+ //showStatus(`Frame: ${frame}`);
1541
+ if (state.isPlaying) {
1542
+ state.playWaitingForFrame = false;
1543
+ }
1544
+ });
1545
+
1546
+ $("#previewImage").on("error", function () {
1547
+ const requestId = Number($(this).attr("data-request-id") || 0);
1548
+ if (requestId !== state.pendingPreviewRequestId) {
1549
+ return;
1550
+ }
1551
+ showStatus("Render error: could not load frame image");
1552
+ if (state.isPlaying) {
1553
+ state.playWaitingForFrame = false;
1554
+ }
1555
+ });
1556
+ }
1557
+
1558
+ function initCircleInteraction() {
1559
+ const $circle = $(".circle");
1560
+ if ($circle.length === 0) {
1561
+ return;
1562
+ }
1563
+
1564
+ const defaultColor = "gray";
1565
+ const defaultOpacity = 0.1;
1566
+
1567
+ const circle = {
1568
+ currentX: 0,
1569
+ currentY: 0,
1570
+ targetX: 0,
1571
+ targetY: 0,
1572
+ easing: 0.22,
1573
+ animationFrame: null,
1574
+
1575
+ init() {
1576
+ $(document).on("mousemove", (e) => {
1577
+ this.targetX = e.clientX;
1578
+ this.targetY = e.clientY;
1579
+ this.updateStateFromPosition(e);
1580
+ });
1581
+ this.animate();
1582
+ },
1583
+
1584
+ animate() {
1585
+ this.currentX += (this.targetX - this.currentX) * this.easing;
1586
+ this.currentY += (this.targetY - this.currentY) * this.easing;
1587
+
1588
+ $circle.css({
1589
+ left: Math.round(this.currentX) + "px",
1590
+ top: Math.round(this.currentY) + "px"
1591
+ });
1592
+
1593
+ this.animationFrame = requestAnimationFrame(() => this.animate());
1594
+ },
1595
+
1596
+ updateStateFromPosition(e) {
1597
+ const elementAtMouse = document.elementFromPoint(e.clientX, e.clientY);
1598
+
1599
+ if (!elementAtMouse) {
1600
+ this.setDefaultState();
1601
+ return;
1602
+ }
1603
+
1604
+ const $clipOrPalette = $(elementAtMouse).closest(".clip, .palette-item");
1605
+ if ($clipOrPalette.length > 0) {
1606
+ const color = window.getComputedStyle($clipOrPalette[0]).backgroundColor;
1607
+ $circle.css("background-color", color);
1608
+ $circle.css("opacity", 0.25);
1609
+ return;
1610
+ }
1611
+
1612
+ const $button = $(elementAtMouse).closest(".timeline-controls button");
1613
+ if ($button.length > 0) {
1614
+ $circle.css("opacity", 0.25);
1615
+ return;
1616
+ }
1617
+
1618
+ this.setDefaultState();
1619
+ },
1620
+
1621
+ setDefaultState() {
1622
+ $circle.css("background-color", defaultColor);
1623
+ $circle.css("opacity", defaultOpacity);
1624
+ }
1625
+ };
1626
+
1627
+ resetCircleHoverState = function () {
1628
+ circle.setDefaultState();
1629
+ };
1630
+
1631
+ circle.init();
1632
+ }
1633
+
1634
+ $(function () {
1635
+ setPreviewHeight(state.previewHeight);
1636
+ $("#previewImage").attr("src", "src/logo_bg.png");
1637
+ bindEvents();
1638
+ setPlayButtonState();
1639
+ setLoopButtonState();
1640
+ setSaveButtonState();
1641
+ setSpeedButtonState();
1642
+ initCircleInteraction();
1643
+ loadState().then(() => {
1644
+ });
1645
+ });
1646
+ })();