lexgui 8.3.1 → 8.3.2

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.
@@ -1,1021 +1,1021 @@
1
- // This is a generated file. Do not edit.
2
- import { LX } from '../core/Namespace.js';
3
-
4
- // VideoEditor.ts @evallsg
5
- if (!LX) {
6
- throw ('Missing LX namespace!');
7
- }
8
- LX.extensions.push('VideoEditor');
9
- const vec2 = LX.vec2;
10
- LX.Area;
11
- LX.Panel;
12
- /**
13
- * @class TimeBar
14
- */
15
- class TimeBar {
16
- static TIMEBAR_PLAY = 1;
17
- static TIMEBAR_TRIM = 2;
18
- static BACKGROUND_COLOR = LX.getCSSVariable('secondary');
19
- static COLOR = LX.getCSSVariable('accent');
20
- static ACTIVE_COLOR = LX.getCSSVariable('color-blue-400');
21
- type = TimeBar.TIMEBAR_PLAY;
22
- duration = 1.0;
23
- canvas;
24
- ctx;
25
- options;
26
- markerWidth = 8;
27
- markerHeight;
28
- offset;
29
- lineWidth;
30
- lineHeight;
31
- linePosition;
32
- startX;
33
- endX;
34
- currentX;
35
- hovering;
36
- dragging;
37
- _onMouseUpListener;
38
- _onMouseMoveListener;
39
- _mouseDownCanvasRect = null;
40
- updateTheme;
41
- onChangeCurrent;
42
- onChangeStart;
43
- onChangeEnd;
44
- onDraw;
45
- onMouse;
46
- constructor(area, type, options = {}) {
47
- this.type = type ?? TimeBar.TIMEBAR_PLAY;
48
- this.options = options ?? {};
49
- this.duration = options.duration ?? this.duration;
50
- // Create canvas
51
- this.canvas = document.createElement('canvas');
52
- this.canvas.style.borderRadius = '6px';
53
- this.canvas.width = area.size[0];
54
- this.canvas.height = area.size[1];
55
- area.attach(this.canvas);
56
- this.ctx = this.canvas.getContext('2d');
57
- this.markerWidth = options.markerWidth ?? this.markerWidth;
58
- this.markerHeight = (options.markerHeight ?? 0.5) * this.canvas.height;
59
- const defaultOffset = this.markerWidth * 0.5 + 5;
60
- if (typeof (options.offset) == 'number') {
61
- this.offset = new vec2(options.offset, options.offset);
62
- }
63
- else if (Array.isArray(options.offset)) {
64
- this.offset = new vec2(options.offset[0] ?? defaultOffset, options.offset[1] ?? defaultOffset);
65
- }
66
- else {
67
- this.offset = new vec2(defaultOffset, defaultOffset);
68
- }
69
- // dimensions of line (not canvas)
70
- this.lineWidth = this.canvas.width - this.offset.x * 2;
71
- this.lineHeight = options.barHeight ?? 5;
72
- this.linePosition = new vec2(this.offset.x, this.canvas.height * 0.5 - this.lineHeight * 0.5);
73
- this.startX = this.linePosition.x;
74
- this.endX = this.linePosition.x + this.lineWidth;
75
- this.currentX = this.startX;
76
- this._draw();
77
- function updateTheme() {
78
- TimeBar.BACKGROUND_COLOR = LX.getCSSVariable('secondary');
79
- TimeBar.COLOR = LX.getCSSVariable('accent');
80
- TimeBar.ACTIVE_COLOR = LX.getCSSVariable('color-blue-400');
81
- }
82
- this.updateTheme = updateTheme.bind(this);
83
- LX.addSignal('@on_new_color_scheme', this.updateTheme);
84
- this.updateTheme();
85
- // prepare event listeners' functions
86
- this._onMouseUpListener = this.onMouseUp.bind(this);
87
- this._onMouseMoveListener = this.onMouseMove.bind(this);
88
- this.canvas.onmousedown = (e) => this.onMouseDown(e);
89
- this.canvas.onmousemove = (e) => {
90
- if (this.dragging)
91
- return; // already handled by _onMouseMoveListener
92
- this.onMouseMove(e);
93
- };
94
- }
95
- unbind() {
96
- removeEventListener('mousemove', this._onMouseMoveListener);
97
- removeEventListener('mouseup', this._onMouseUpListener);
98
- }
99
- setDuration(duration) {
100
- this.duration = duration;
101
- }
102
- xToTime(x) {
103
- return ((x - this.offset.x) / (this.lineWidth)) * this.duration;
104
- }
105
- timeToX(time) {
106
- return (time / this.duration) * (this.lineWidth) + this.offset.x;
107
- }
108
- setCurrentTime(time) {
109
- this.currentX = this.timeToX(time);
110
- this.onSetCurrentValue(this.currentX);
111
- }
112
- setStartTime(time) {
113
- this.startX = this.timeToX(time);
114
- this.onSetStartValue(this.startX);
115
- }
116
- setEndTime(time) {
117
- this.endX = this.timeToX(time);
118
- this.onSetEndValue(this.endX);
119
- }
120
- onSetCurrentValue(x) {
121
- this.update(x);
122
- const t = this.xToTime(x);
123
- if (this.onChangeCurrent) {
124
- this.onChangeCurrent(t);
125
- }
126
- }
127
- onSetStartValue(x) {
128
- this.update(x);
129
- const t = this.xToTime(x);
130
- if (this.onChangeStart) {
131
- this.onChangeStart(t);
132
- }
133
- }
134
- onSetEndValue(x) {
135
- this.update(x);
136
- const t = this.xToTime(x);
137
- if (this.onChangeEnd) {
138
- this.onChangeEnd(t);
139
- }
140
- }
141
- _draw() {
142
- const ctx = this.ctx;
143
- if (!ctx)
144
- return;
145
- ctx.save();
146
- ctx.fillStyle = TimeBar.BACKGROUND_COLOR;
147
- ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
148
- ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
149
- // Draw background timeline
150
- ctx.fillStyle = TimeBar.COLOR;
151
- ctx.fillRect(this.linePosition.x, this.linePosition.y, this.lineWidth, this.lineHeight);
152
- // Draw background trimed timeline
153
- ctx.fillStyle = TimeBar.ACTIVE_COLOR;
154
- ctx.fillRect(this.startX, this.linePosition.y, this.endX - this.startX, this.lineHeight);
155
- ctx.restore();
156
- // Min-Max time markers
157
- this._drawTrimMarker('start', this.startX, { color: null, fillColor: TimeBar.ACTIVE_COLOR || '#5f88c9' });
158
- this._drawTrimMarker('end', this.endX, { color: null, fillColor: TimeBar.ACTIVE_COLOR || '#5f88c9' });
159
- this._drawTimeMarker('current', this.currentX, { color: '#e5e5e5', fillColor: TimeBar.ACTIVE_COLOR || '#5f88c9', width: this.markerWidth });
160
- if (this.onDraw) {
161
- this.onDraw();
162
- }
163
- }
164
- _drawTrimMarker(name, x, options = {}) {
165
- const w = this.markerWidth;
166
- const h = this.markerHeight;
167
- const y = this.canvas.height * 0.5 - h * 0.5;
168
- const ctx = this.ctx;
169
- if (!ctx)
170
- return;
171
- // Shadow
172
- if (this.hovering == name) {
173
- ctx.shadowColor = 'white';
174
- ctx.shadowBlur = 2;
175
- }
176
- ctx.globalAlpha = 1;
177
- ctx.fillStyle = ctx.strokeStyle = options.fillColor || '#111'; // "#FFF";
178
- ctx.beginPath();
179
- ctx.roundRect(x - w * 0.5, y, w, h, 2);
180
- ctx.fill();
181
- ctx.fillStyle = ctx.strokeStyle = options.fillColor || '#111'; // "#FFF";
182
- ctx.strokeStyle = 'white';
183
- ctx.beginPath();
184
- ctx.lineWidth = 2;
185
- ctx.moveTo(x, y + 4);
186
- ctx.lineTo(x, y + h - 4);
187
- ctx.stroke();
188
- ctx.shadowBlur = 0;
189
- }
190
- _drawTimeMarker(name, x, options = {}) {
191
- let y = this.offset.y;
192
- const w = options.width ? options.width : (this.dragging == name ? 6 : 4);
193
- this.canvas.height - this.offset.y * 2;
194
- let ctx = this.ctx;
195
- if (!ctx)
196
- return;
197
- ctx.globalAlpha = 1;
198
- ctx.fillStyle = ctx.strokeStyle = options.fillColor || '#111'; // "#FFF";
199
- // Shadow
200
- if (this.hovering == name) {
201
- ctx.shadowColor = 'white';
202
- ctx.shadowBlur = 2;
203
- }
204
- // Current time line
205
- ctx.fillStyle = ctx.strokeStyle = 'white';
206
- ctx.beginPath();
207
- ctx.moveTo(x, y);
208
- ctx.lineTo(x, this.linePosition.y + this.lineHeight * 0.5);
209
- ctx.stroke();
210
- ctx.closePath();
211
- ctx.fillStyle = ctx.strokeStyle = options.fillColor || '#111'; // "#FFF";
212
- // Current time ball grab
213
- ctx.fillStyle = options.fillColor || '#e5e5e5';
214
- ctx.beginPath();
215
- ctx.roundRect(x - w * 0.5, y - w * 0.5, w, w, 5);
216
- ctx.fill();
217
- ctx.shadowBlur = 0;
218
- }
219
- update(x) {
220
- this.currentX = Math.min(Math.max(this.startX, x), this.endX);
221
- this._draw();
222
- }
223
- onMouseDown(e) {
224
- if (this.onMouse) {
225
- this.onMouse(e);
226
- }
227
- e.preventDefault();
228
- if (!this.canvas || e.target != this.canvas || e.cancelBubble) {
229
- return;
230
- }
231
- const canvas = this.canvas;
232
- // Process mouse
233
- const x = e.offsetX;
234
- const y = e.offsetY;
235
- // Check if some marker is clicked
236
- const threshold = this.markerWidth;
237
- const startDist = Math.abs(this.startX - x);
238
- const endDist = Math.abs(this.endX - x);
239
- // grab trim markers only from the bottom
240
- if ((startDist < threshold || endDist < threshold) && this.linePosition.y < y) {
241
- this.dragging = (startDist < endDist || x < this.startX) ? 'start' : 'end';
242
- canvas.style.cursor = 'grabbing';
243
- }
244
- else {
245
- this.dragging = 'current';
246
- canvas.style.cursor = 'grabbing';
247
- if (x < this.startX) {
248
- this.currentX = this.startX;
249
- }
250
- else if (x > this.endX) {
251
- this.currentX = this.endX;
252
- }
253
- else {
254
- this.currentX = x;
255
- }
256
- this.onSetCurrentValue(this.currentX);
257
- }
258
- this._mouseDownCanvasRect = canvas.getBoundingClientRect(); // cache this to avoid stalls during mousemove
259
- window.addEventListener('mousemove', this._onMouseMoveListener);
260
- window.addEventListener('mouseup', this._onMouseUpListener);
261
- this._draw();
262
- }
263
- onMouseUp(e) {
264
- window.removeEventListener('mousemove', this._onMouseMoveListener);
265
- window.removeEventListener('mouseup', this._onMouseUpListener);
266
- if (this.onMouse) {
267
- this.onMouse(e);
268
- }
269
- e.preventDefault();
270
- delete this.dragging;
271
- delete this.hovering;
272
- if (!this.canvas || e.cancelBubble) {
273
- return;
274
- }
275
- const canvas = this.canvas;
276
- canvas.style.cursor = 'default';
277
- }
278
- onMouseMove(e) {
279
- if (this.onMouse) {
280
- this.onMouse(e);
281
- }
282
- if (!this.canvas || e.cancelBubble) {
283
- return;
284
- }
285
- e.preventDefault();
286
- const canvas = this.canvas;
287
- // Process mouse
288
- const x = e.target == canvas ? e.offsetX : (e.clientX - this._mouseDownCanvasRect.left);
289
- e.target == canvas ? e.offsetY : (e.clientY - this._mouseDownCanvasRect.top);
290
- if (this.dragging) {
291
- switch (this.dragging) {
292
- case 'start':
293
- this.startX = Math.max(this.linePosition.x, Math.min(this.endX, x));
294
- this.currentX = this.startX;
295
- this.onSetStartValue(this.startX);
296
- break;
297
- case 'end':
298
- this.endX = Math.max(this.startX, Math.min(this.linePosition.x + this.lineWidth, x));
299
- this.currentX = this.endX;
300
- this.onSetEndValue(this.endX);
301
- break;
302
- default:
303
- this.currentX = Math.max(this.startX, Math.min(this.endX, x));
304
- break;
305
- }
306
- this.onSetCurrentValue(this.currentX);
307
- }
308
- else {
309
- const threshold = this.markerWidth * 0.5;
310
- if (Math.abs(this.startX - x) < threshold) {
311
- this.hovering = 'start';
312
- canvas.style.cursor = 'grab';
313
- }
314
- else if (Math.abs(this.endX - x) < threshold) {
315
- this.hovering = 'end';
316
- canvas.style.cursor = 'grab';
317
- }
318
- else if (Math.abs(this.currentX - x) < threshold) {
319
- this.hovering = 'current';
320
- canvas.style.cursor = 'grab';
321
- }
322
- else {
323
- delete this.hovering;
324
- canvas.style.cursor = 'default';
325
- }
326
- }
327
- this._draw();
328
- }
329
- resize(size) {
330
- this.canvas.width = Math.max(0, size[0]);
331
- this.canvas.height = Math.max(0, size[1]);
332
- this.markerHeight = (this.options.markerHeight ?? 0.5) * this.canvas.height;
333
- let newWidth = size[0] - this.offset.x * 2;
334
- newWidth = newWidth < 0.00001 ? 0.00001 : newWidth; // actual width of the line = canvas.width - offsetleft - offsetRight
335
- const startRatio = (this.startX - this.offset.x) / this.lineWidth;
336
- const currentRatio = (this.currentX - this.offset.x) / this.lineWidth;
337
- const endRatio = (this.endX - this.offset.x) / this.lineWidth;
338
- this.lineWidth = newWidth;
339
- this.linePosition.x = this.offset.x;
340
- this.linePosition.y = this.canvas.height * 0.5 - this.lineHeight * 0.5;
341
- this.startX = Math.min(Math.max(newWidth * startRatio, 0), newWidth) + this.offset.x;
342
- this.currentX = Math.min(Math.max(newWidth * currentRatio, 0), newWidth) + this.offset.x;
343
- this.endX = Math.min(Math.max(newWidth * endRatio, 0), newWidth) + this.offset.x;
344
- this._draw();
345
- }
346
- }
347
- LX.TimeBar = TimeBar;
348
- /**
349
- * @class VideoEditor
350
- */
351
- class VideoEditor {
352
- static CROP_HANDLE_L = 0x01;
353
- static CROP_HANDLE_R = 0x02;
354
- static CROP_HANDLE_T = 0x04;
355
- static CROP_HANDLE_B = 0x08;
356
- static CROP_HANDLE_TL = VideoEditor.CROP_HANDLE_L | VideoEditor.CROP_HANDLE_T;
357
- static CROP_HANDLE_BL = VideoEditor.CROP_HANDLE_L | VideoEditor.CROP_HANDLE_B;
358
- static CROP_HANDLE_TR = VideoEditor.CROP_HANDLE_R | VideoEditor.CROP_HANDLE_T;
359
- static CROP_HANDLE_BR = VideoEditor.CROP_HANDLE_R | VideoEditor.CROP_HANDLE_B;
360
- options = {};
361
- playing = false;
362
- videoReady = false;
363
- controls = true;
364
- speed = 1.0;
365
- startTime = 0.0;
366
- endTime = 0.0;
367
- requestId;
368
- video;
369
- loop = false;
370
- isDragging = false;
371
- isResizing = null; // holds the HTMLElement of the crop handle, if resizing
372
- crop = false;
373
- dragOffsetX = 0.0;
374
- dragOffsetY = 0.0;
375
- timebar = null;
376
- mainArea;
377
- cropArea; // HTMLElement with normCoord attribute;
378
- videoArea;
379
- controlsArea;
380
- controlsComponents;
381
- onChangeCurrent;
382
- onChangeStart;
383
- onChangeEnd;
384
- onKeyUp;
385
- onSetTime;
386
- onVideoLoaded;
387
- onResize;
388
- onCropArea;
389
- onChangeSpeed;
390
- onChangeState;
391
- onChangeLoop;
392
- _updateTime = true;
393
- _onCropMouseUp;
394
- _onCropMouseMove;
395
- resize = null;
396
- resizeControls = null;
397
- resizeVideo = null;
398
- constructor(area, options = {}) {
399
- this.options = options ?? {};
400
- const controlsOptions = this.options.controlsLayout ?? {};
401
- this.speed = options.speed ?? this.speed;
402
- this.mainArea = area;
403
- let videoArea = null;
404
- let controlsArea = null;
405
- if (options.controlsArea) {
406
- videoArea = area;
407
- controlsArea = options.controlsArea;
408
- }
409
- else {
410
- [videoArea, controlsArea] = area.split({ type: 'vertical',
411
- sizes: [controlsOptions.height ? `calc(100% - ${controlsOptions.height})` : '85%', null], minimizable: false, resize: false });
412
- }
413
- controlsArea.root.classList.add('lexconstrolsarea');
414
- this.cropArea = document.createElement('div');
415
- this.cropArea.id = 'cropArea';
416
- this.cropArea.className = 'resize-area hidden';
417
- this.cropArea.normCoords = { x: 0, y: 0, w: 1, h: 1 };
418
- const flags = 0x0f;
419
- this.setCropAreaHandles(flags);
420
- this.crop = options.crop;
421
- this.dragOffsetX = 0;
422
- this.dragOffsetY = 0;
423
- // Create video element and load it
424
- let video = this.video = options.video ?? document.createElement('video');
425
- this.loop = options.loop ?? this.loop;
426
- if (options.src) {
427
- this.video.src = options.src;
428
- this.loadVideo(options);
429
- }
430
- if (options.videoArea) {
431
- options.videoArea.root.classList.add('lexvideoeditor');
432
- options.videoArea.root.style.position = 'relative';
433
- options.videoArea.attach(this.cropArea);
434
- videoArea.attach(options.videoArea);
435
- }
436
- else {
437
- videoArea.attach(video);
438
- videoArea.attach(this.cropArea);
439
- videoArea.root.classList.add('lexvideoeditor');
440
- }
441
- videoArea.root.style.position = 'relative';
442
- this.videoArea = videoArea;
443
- this.controlsArea = controlsArea;
444
- this.controlsComponents = {
445
- timebar: null,
446
- playBtn: null,
447
- speedBtn: null,
448
- loopBtn: null,
449
- trimStartText: null,
450
- trimEndText: null,
451
- curTimeText: null,
452
- resetCropBtn: null
453
- };
454
- this.createControls();
455
- this.resizeVideo = () => {
456
- this.moveCropArea(this.cropArea.normCoords.x, this.cropArea.normCoords.y, true);
457
- this.resizeCropArea(this.cropArea.normCoords.w, this.cropArea.normCoords.h, true);
458
- if (this.onResize) {
459
- this.onResize([videoArea.root.clientWidth, videoArea.root.clientHeight]);
460
- }
461
- };
462
- this.resize = () => {
463
- this.resizeVideo();
464
- this.resizeControls();
465
- };
466
- area.onresize = this.resize.bind(this);
467
- window.addEventListener('resize', area.onresize);
468
- this.onKeyUp = (e) => {
469
- if (this.controls && e.key == ' ') {
470
- e.preventDefault();
471
- e.stopPropagation();
472
- // do not skip callback
473
- this.controlsComponents.playBtn?.setState(!this.playing, false);
474
- }
475
- };
476
- window.addEventListener('keyup', this.onKeyUp);
477
- this._onCropMouseUp = (event) => {
478
- event.preventDefault();
479
- event.stopPropagation();
480
- if ((this.isDragging || this.isResizing) && this.onCropArea) {
481
- this.onCropArea(this.getCroppedArea());
482
- }
483
- this.isDragging = false;
484
- this.isResizing = false;
485
- document.removeEventListener('mouseup', this._onCropMouseUp); // self destroy. Added during mouseDown on cropArea and handles
486
- document.removeEventListener('mousemove', this._onCropMouseMove); // self destroy. Added during mouseDown on cropArea and handles
487
- };
488
- this._onCropMouseMove = (event) => {
489
- window.getSelection()?.removeAllRanges();
490
- event.preventDefault();
491
- event.stopPropagation();
492
- if (this.isResizing) {
493
- const rectCrop = this.cropArea.getBoundingClientRect();
494
- const rectVideo = this.video.getBoundingClientRect();
495
- const mov = this.isResizing.movement;
496
- let x = rectCrop.left, y = rectCrop.top, w = rectCrop.width, h = rectCrop.height;
497
- if (mov & VideoEditor.CROP_HANDLE_L) {
498
- let mouseX = Math.min(rectCrop.right - 4, Math.max(rectVideo.left, event.clientX)); // -4 because of border
499
- w = rectCrop.left + rectCrop.width - mouseX;
500
- x = mouseX;
501
- if (mouseX < rectCrop.left) {
502
- this.moveCropArea(x, y, false);
503
- this.resizeCropArea(w, h, false);
504
- }
505
- else {
506
- this.resizeCropArea(w, h, false);
507
- this.moveCropArea(x, y, false);
508
- }
509
- }
510
- if (mov & VideoEditor.CROP_HANDLE_R) {
511
- w = event.clientX - rectCrop.left;
512
- this.resizeCropArea(w, h, false);
513
- }
514
- if (mov & VideoEditor.CROP_HANDLE_T) {
515
- const mouseY = Math.min(rectCrop.bottom - 4, Math.max(rectVideo.top, event.clientY));
516
- h = rectCrop.top + rectCrop.height - mouseY;
517
- y = mouseY;
518
- if (mouseY < rectCrop.top) {
519
- this.moveCropArea(x, y, false);
520
- this.resizeCropArea(w, h, false);
521
- }
522
- else {
523
- this.resizeCropArea(w, h, false);
524
- this.moveCropArea(x, y, false);
525
- }
526
- }
527
- if (mov & VideoEditor.CROP_HANDLE_B) {
528
- h = event.clientY - rectCrop.top;
529
- this.resizeCropArea(w, h, false);
530
- }
531
- }
532
- if (this.isDragging) {
533
- this.moveCropArea(event.clientX - this.dragOffsetX, event.clientY - this.dragOffsetY, false);
534
- }
535
- };
536
- this.cropArea.addEventListener('mousedown', (e) => {
537
- if (e.target === this.cropArea) {
538
- const rect = this.cropArea.getBoundingClientRect();
539
- this.isDragging = true;
540
- this.dragOffsetX = e.clientX - rect.left;
541
- this.dragOffsetY = e.clientY - rect.top;
542
- document.addEventListener('mouseup', this._onCropMouseUp);
543
- document.addEventListener('mousemove', this._onCropMouseMove);
544
- }
545
- });
546
- this.onChangeStart = null;
547
- this.onChangeEnd = null;
548
- }
549
- createControls(controlsLayoutOptions = null) {
550
- const controlsArea = this.controlsArea;
551
- if (controlsLayoutOptions) {
552
- this.options.controlsLayout = controlsLayoutOptions;
553
- }
554
- const controlsOptions = this.options.controlsLayout ?? {};
555
- // clear area. Signals are not cleared !!! (not a problem if there are no signals)
556
- while (controlsArea.root.children.length) {
557
- controlsArea.root.children[0].remove();
558
- }
559
- controlsArea.sections.length = 0;
560
- // start trimming text
561
- this.controlsComponents.trimStartText = new LX.TextInput(null, this.timeToString(this.startTime), null, { width: '100px',
562
- title: 'Trimmed Start Time', disabled: true, inputClass: 'bg-none' });
563
- this.controlsComponents.trimEndText = new LX.TextInput(null, this.timeToString(this.endTime), null, { width: '100px',
564
- title: 'Trimmed End Time', disabled: true, inputClass: 'bg-none' });
565
- this.controlsComponents.curTimeText = new LX.TextInput(null, this.video.currentTime, null, { title: 'Current Time', float: 'center',
566
- disabled: true, inputClass: 'bg-none' });
567
- // reset crop area
568
- this.controlsComponents.resetCropBtn = new LX.Button('ResetCrop', null, (v) => {
569
- this.moveCropArea(0, 0, true);
570
- this.resizeCropArea(1, 1, true);
571
- if (this.onCropArea) {
572
- this.onCropArea(this.getCroppedArea());
573
- }
574
- }, { width: '40px', title: 'Reset Crop Area', icon: 'Crop@solid', hideName: true,
575
- className: 'justify-center' + (this.crop ? '' : ' hidden') });
576
- // play button
577
- this.controlsComponents.playBtn = new LX.Button('Play', '', (v) => {
578
- this.playing = v;
579
- if (this.playing) {
580
- if (this.video.currentTime + 0.000001 >= this.endTime) {
581
- this.video.currentTime = this.startTime;
582
- }
583
- this.video.play();
584
- }
585
- else {
586
- this.video.pause();
587
- }
588
- if (this.onChangeState) {
589
- this.onChangeState(v);
590
- }
591
- }, { width: '40px', title: 'Play/Pause', icon: 'Play@solid', swap: 'Pause@solid', hideName: true, className: 'justify-center' });
592
- this.controlsComponents.playBtn.setState(this.playing, true);
593
- // speed button
594
- this.controlsComponents.speedBtn = new LX.Button('Speed', '', (v, e) => {
595
- const panel = new LX.Panel();
596
- panel.addRange('Speed', this.speed, (v) => {
597
- this.speed = v;
598
- this.video.playbackRate = v;
599
- if (this.onChangeSpeed) {
600
- this.onChangeSpeed(v);
601
- }
602
- }, { min: 0, max: 2.5, step: 0.01, hideName: true });
603
- new LX.Popover(e.target, [panel], { align: 'start', side: 'top', sideOffset: 12 });
604
- }, { width: '40px', title: 'Speed', hideName: true, icon: 'Timer@solid', className: 'justify-center' });
605
- // loop button
606
- this.controlsComponents.loopBtn = new LX.Button('', 'Loop', (v) => {
607
- this.loop = v;
608
- if (this.onChangeLoop) {
609
- this.onChangeLoop(v);
610
- }
611
- }, { width: '40px', hideName: true, title: 'Loop', icon: 'Repeat@solid', className: `justify-center`, selectable: true,
612
- selected: this.loop });
613
- let timeBarArea = null;
614
- if (typeof (controlsOptions.type) == 'function') {
615
- timeBarArea = controlsOptions.type;
616
- }
617
- else if (controlsOptions.type == 1) {
618
- timeBarArea = this._createControlsLayout_1();
619
- }
620
- else {
621
- timeBarArea = this._createControlsLayout_0();
622
- }
623
- if (this.timebar) {
624
- this.timebar.unbind();
625
- }
626
- this.timebar = this.controlsComponents.timebar = new TimeBar(timeBarArea, TimeBar.TIMEBAR_TRIM, { offset: [12, null] });
627
- this.timebar.onChangeCurrent = this._setCurrentTime.bind(this);
628
- this.timebar.onChangeStart = this._setStartTime.bind(this);
629
- this.timebar.onChangeEnd = this._setEndTime.bind(this);
630
- let duration = 1;
631
- if (this.video.duration !== Infinity && !isNaN(this.video.duration)) {
632
- duration = this.video.duration;
633
- }
634
- this.timebar.setDuration(duration);
635
- this.timebar.setEndTime(this.endTime);
636
- this.timebar.setStartTime(this.startTime);
637
- this.timebar.setCurrentTime(this.startTime);
638
- this.resizeControls();
639
- }
640
- /**
641
- * Creates the areas where components will be.
642
- * Attaches all (desired) components of controlsComponents except the timebar
643
- * @returns {Area} for the timebar
644
- * Layout:
645
- * |--------------------------timebar--------------------------|
646
- * play speed loop resetCrop curTime trimStart / trimEnd
647
- */
648
- _createControlsLayout_1() {
649
- const controlsArea = this.controlsArea;
650
- const options = this.options.controlsLayout ?? {};
651
- // Create playing timeline area and attach panels
652
- let [timeBarArea, bottomArea] = controlsArea.split({ type: 'vertical', sizes: [options.l1TimelineHeight ?? '50%', null],
653
- minimizable: false, resize: false });
654
- bottomArea.root.classList.add('relative');
655
- let separator = document.createElement('p');
656
- separator.style.alignContent = 'center';
657
- separator.innerText = '/';
658
- let trimDiv = LX.makeContainer(['fit-content', '100%'], 'relative flex flex-row pb-2', null, bottomArea, { float: 'right' });
659
- trimDiv.appendChild(this.controlsComponents.trimStartText.root);
660
- trimDiv.appendChild(separator);
661
- trimDiv.appendChild(this.controlsComponents.trimEndText.root);
662
- this.controlsComponents.trimStartText.root.querySelector('input').classList.add('text-end');
663
- this.controlsComponents.trimStartText.root.classList.add('top-0', 'bottom-0');
664
- this.controlsComponents.trimEndText.root.classList.add('top-0', 'bottom-0');
665
- // current time
666
- let curTimeDiv = LX.makeContainer(['100%', '100%'], 'absolute top-0 left-0 flex flex-row justify-center items-center pb-2', null, bottomArea, {});
667
- curTimeDiv.appendChild(this.controlsComponents.curTimeText.root);
668
- // Buttons
669
- const buttonsPanel = bottomArea.addPanel({ className: 'absolute top-0 left-0 flex flex-row pl-4 pr-4 pt-1 pb-2' });
670
- buttonsPanel.root.classList.remove('pad-md');
671
- buttonsPanel._attachComponent(this.controlsComponents.playBtn);
672
- buttonsPanel._attachComponent(this.controlsComponents.speedBtn);
673
- buttonsPanel._attachComponent(this.controlsComponents.loopBtn);
674
- buttonsPanel._attachComponent(this.controlsComponents.resetCropBtn);
675
- this.controlsComponents.playBtn.root.classList.add('pl-0');
676
- this.controlsComponents.resetCropBtn.root.classList.add('pr-0');
677
- // timebar
678
- timeBarArea.root.classList.add('p-4', 'pb-0');
679
- this.resizeControls = () => {
680
- const style = getComputedStyle(timeBarArea.root);
681
- let pleft = parseFloat(style.paddingLeft);
682
- let pright = parseFloat(style.paddingRight);
683
- let ptop = parseFloat(style.paddingTop);
684
- let pbot = parseFloat(style.paddingBottom);
685
- // assuming timeBarArea will not overflow
686
- this.timebar.resize([timeBarArea.root.clientWidth - pleft - pright, timeBarArea.root.clientHeight - ptop - pbot]);
687
- };
688
- return timeBarArea;
689
- }
690
- /**
691
- * Creates the areas where components will be.
692
- * Attaches all (desired) components of controlsComponents except the timebar
693
- * @returns {Area} for the timebar
694
- * Layout:
695
- * curTime
696
- * play speed loop trimStart |---timebar---| trimend
697
- */
698
- _createControlsLayout_0() {
699
- const controlsArea = this.controlsArea;
700
- // Create playing timeline area and attach panels
701
- let [topArea, bottomArea] = controlsArea.split({ type: 'vertical', sizes: ['50%', null], minimizable: false, resize: false });
702
- bottomArea.setSize([bottomArea.size[0], 40]);
703
- let [leftArea, controlsRight] = bottomArea.split({ type: 'horizontal', sizes: ['92%', null], minimizable: false, resize: false });
704
- let [controlsLeft, timeBarArea] = leftArea.split({ type: 'horizontal', sizes: ['10%', null], minimizable: false, resize: false });
705
- const controlsCurrentPanel = topArea.addPanel({ className: 'flex' });
706
- controlsCurrentPanel._attachComponent(this.controlsComponents.curTimeText);
707
- // Create controls panel (play/pause button and start time)
708
- controlsLeft.root.classList.add('min-w-fit');
709
- const controlsPanelLeft = controlsLeft.addPanel({ className: 'lexcontrolspanel p-0 pl-2' });
710
- controlsPanelLeft.root.classList.remove('pad-md');
711
- controlsPanelLeft.sameLine();
712
- controlsPanelLeft._attachComponent(this.controlsComponents.playBtn);
713
- controlsPanelLeft._attachComponent(this.controlsComponents.speedBtn);
714
- controlsPanelLeft._attachComponent(this.controlsComponents.loopBtn);
715
- controlsPanelLeft._attachComponent(this.controlsComponents.trimStartText);
716
- controlsPanelLeft.endLine();
717
- // Create right controls panel (end time)
718
- controlsRight.root.classList.add('min-w-fit');
719
- const controlsPanelRight = controlsRight.addPanel({ className: 'lexcontrolspanel p-0' });
720
- controlsPanelRight.root.classList.remove('pad-md');
721
- controlsPanelRight._attachComponent(this.controlsComponents.trimEndText);
722
- this.resizeControls = () => {
723
- bottomArea.setSize([this.controlsArea.root.clientWidth, 40]);
724
- let availableWidth = this.controlsArea.root.clientWidth - controlsLeft.root.clientWidth
725
- - controlsRight.root.clientWidth;
726
- this.timebar.resize([availableWidth, timeBarArea.root.clientHeight]);
727
- };
728
- return timeBarArea;
729
- }
730
- setCropAreaHandles(flags) {
731
- // remove existing resizer handles
732
- const resizers = this.cropArea.getElementsByClassName('resize-handle');
733
- for (let i = resizers.length - 1; i > -1; --i) {
734
- resizers[i].remove();
735
- }
736
- const buildResizer = (className, movement) => {
737
- const handle = document.createElement('div');
738
- handle.className = ' resize-handle ' + className;
739
- handle.movement = movement;
740
- if (this.options.handleStyle) {
741
- Object.assign(handle.style, this.options.handleStyle);
742
- }
743
- this.cropArea.append(handle);
744
- handle.addEventListener('mousedown', (e) => {
745
- e.stopPropagation();
746
- e.preventDefault();
747
- this.isResizing = handle;
748
- document.addEventListener('mouseup', this._onCropMouseUp);
749
- document.addEventListener('mousemove', this._onCropMouseMove);
750
- });
751
- };
752
- if (flags & VideoEditor.CROP_HANDLE_L)
753
- buildResizer('l', VideoEditor.CROP_HANDLE_L);
754
- if (flags & VideoEditor.CROP_HANDLE_R)
755
- buildResizer('r', VideoEditor.CROP_HANDLE_R);
756
- if (flags & VideoEditor.CROP_HANDLE_T)
757
- buildResizer('t', VideoEditor.CROP_HANDLE_T);
758
- if (flags & VideoEditor.CROP_HANDLE_B)
759
- buildResizer('b', VideoEditor.CROP_HANDLE_B);
760
- if ((flags & VideoEditor.CROP_HANDLE_TL) == VideoEditor.CROP_HANDLE_TL) {
761
- buildResizer('tl', VideoEditor.CROP_HANDLE_TL);
762
- }
763
- if ((flags & VideoEditor.CROP_HANDLE_BL) == VideoEditor.CROP_HANDLE_BL) {
764
- buildResizer('bl', VideoEditor.CROP_HANDLE_BL);
765
- }
766
- if ((flags & VideoEditor.CROP_HANDLE_TR) == VideoEditor.CROP_HANDLE_TR) {
767
- buildResizer('tr', VideoEditor.CROP_HANDLE_TR);
768
- }
769
- if ((flags & VideoEditor.CROP_HANDLE_BR) == VideoEditor.CROP_HANDLE_BR) {
770
- buildResizer('br', VideoEditor.CROP_HANDLE_BR);
771
- }
772
- }
773
- resizeCropArea(sx, sy, isNormalized = true) {
774
- const rectVideo = this.video.getBoundingClientRect();
775
- if (!isNormalized) {
776
- sx = (rectVideo.width) ? (sx / rectVideo.width) : 1;
777
- sy = (rectVideo.height) ? (sy / rectVideo.height) : 1;
778
- }
779
- sx = Math.min(1 - this.cropArea.normCoords.x, Math.max(0, sx));
780
- sy = Math.min(1 - this.cropArea.normCoords.y, Math.max(0, sy));
781
- this.cropArea.normCoords.w = sx;
782
- this.cropArea.normCoords.h = sy;
783
- const widthPx = rectVideo.width * sx;
784
- const heightPx = rectVideo.height * sy;
785
- const xPx = rectVideo.width * this.cropArea.normCoords.x + rectVideo.left;
786
- const yPx = rectVideo.height * this.cropArea.normCoords.y + rectVideo.top;
787
- if (!this.cropArea.classList.contains('hidden')) {
788
- const nodes = this.cropArea.parentElement.childNodes;
789
- for (let i = 0; i < nodes.length; i++) {
790
- if (nodes[i] != this.cropArea) {
791
- const rectEl = nodes[i].getBoundingClientRect();
792
- nodes[i].style.webkitMask = `linear-gradient(#000 0 0) ${xPx - rectEl.left}px ${yPx - rectEl.top}px / ${widthPx}px ${heightPx}px, linear-gradient(rgba(0, 0, 0, 0.3) 0 0)`;
793
- nodes[i].style.webkitMaskRepeat = 'no-repeat';
794
- }
795
- }
796
- }
797
- this.cropArea.style.width = widthPx + 'px';
798
- this.cropArea.style.height = heightPx + 'px';
799
- }
800
- // screen pixel (event.clientX) or video normalized (0 is top left of video, 1 bot right)
801
- moveCropArea(x, y, isNormalized = true) {
802
- const rectVideo = this.video.getBoundingClientRect();
803
- if (!isNormalized) {
804
- x = (rectVideo.width) ? ((x - rectVideo.left) / rectVideo.width) : 0;
805
- y = (rectVideo.height) ? ((y - rectVideo.top) / rectVideo.height) : 0;
806
- }
807
- x = Math.max(0, Math.min(1 - this.cropArea.normCoords.w, x));
808
- y = Math.max(0, Math.min(1 - this.cropArea.normCoords.h, y));
809
- this.cropArea.normCoords.x = x;
810
- this.cropArea.normCoords.y = y;
811
- const xPx = rectVideo.width * x + rectVideo.left;
812
- const yPx = rectVideo.height * y + rectVideo.top;
813
- const widthPx = rectVideo.width * this.cropArea.normCoords.w;
814
- const heightPx = rectVideo.height * this.cropArea.normCoords.h;
815
- if (!this.cropArea.classList.contains('hidden')) {
816
- const nodes = this.cropArea.parentElement.childNodes;
817
- for (let i = 0; i < nodes.length; i++) {
818
- if (nodes[i] != this.cropArea) {
819
- const rectEl = nodes[i].getBoundingClientRect();
820
- nodes[i].style.webkitMask = `linear-gradient(#000 0 0) ${xPx - rectEl.left}px ${yPx - rectEl.top}px / ${widthPx}px ${heightPx}px, linear-gradient(rgba(0, 0, 0, 0.3) 0 0)`;
821
- nodes[i].style.webkitMaskRepeat = 'no-repeat';
822
- }
823
- }
824
- }
825
- const rectParent = this.cropArea.parentElement.getBoundingClientRect();
826
- this.cropArea.style.left = xPx - rectParent.left + 'px';
827
- this.cropArea.style.top = yPx - rectParent.top + 'px';
828
- }
829
- async loadVideo(options = {}) {
830
- this.videoReady = false;
831
- while (this.video.duration === Infinity || isNaN(this.video.duration) || !this.timebar) {
832
- await new Promise((r) => setTimeout(r, 1000));
833
- this.video.currentTime = 10000000 * Math.random();
834
- }
835
- this.video.currentTime = 0.01; // BUG: some videos will not play unless this line is present
836
- // Duration can change if the video is dynamic (stream). This function is to ensure to load all buffer data
837
- const forceLoadChunks = () => {
838
- const state = this.videoReady;
839
- if (this.video.readyState > 3) {
840
- this.videoReady = true;
841
- }
842
- if (!state) {
843
- this.video.currentTime = this.video.duration;
844
- }
845
- };
846
- this.video.addEventListener('canplaythrough', forceLoadChunks, { passive: true });
847
- this.video.ondurationchange = (v) => {
848
- if (this.video.duration != this.endTime) {
849
- this.video.currentTime = this.startTime;
850
- console.log('duration changed from', this.endTime, ' to ', this.video.duration);
851
- this.endTime = this.video.duration;
852
- this.timebar.setDuration(this.endTime);
853
- this.timebar.setEndTime(this.endTime);
854
- }
855
- this.video.currentTime = this.startTime;
856
- this.timebar.setCurrentTime(this.video.currentTime);
857
- };
858
- this.startTime = 0;
859
- this.endTime = this.video.duration;
860
- this.timebar.setDuration(this.endTime);
861
- this.timebar.setEndTime(this.video.duration);
862
- this.timebar.setStartTime(this.startTime);
863
- this.timebar.setCurrentTime(this.startTime);
864
- // only have one update on flight
865
- if (!this.requestId) {
866
- this._update();
867
- }
868
- this.controls = options.controls ?? true;
869
- if (!this.controls) {
870
- this.hideControls();
871
- }
872
- this.cropArea.style.height = this.video.clientHeight + 'px';
873
- this.cropArea.style.width = this.video.clientWidth + 'px';
874
- this.moveCropArea(0, 0, true);
875
- this.resizeCropArea(1, 1, true);
876
- if (this.crop) {
877
- this.showCropArea();
878
- }
879
- else {
880
- this.hideCropArea();
881
- }
882
- window.addEventListener('keyup', this.onKeyUp);
883
- if (this.onVideoLoaded) {
884
- this.onVideoLoaded(this.video);
885
- }
886
- }
887
- _update() {
888
- // if( this.onDraw ) {
889
- // this.onDraw();
890
- // }
891
- if (this.playing) {
892
- if (this.video.currentTime + 0.000001 >= this.endTime) {
893
- this.video.pause();
894
- if (!this.loop) {
895
- this.playing = false;
896
- this.controlsComponents.playBtn?.setState(false, true); // skip callback
897
- }
898
- else {
899
- this.video.currentTime = this.startTime;
900
- this.video.play();
901
- }
902
- }
903
- this._updateTime = false;
904
- this.timebar.setCurrentTime(this.video.currentTime);
905
- this._updateTime = true;
906
- }
907
- this.requestId = requestAnimationFrame(this._update.bind(this));
908
- }
909
- timeToString(t) {
910
- let mzminutes = Math.floor(t / 60);
911
- let mzseconds = Math.floor(t - (mzminutes * 60));
912
- let mzmiliseconds = Math.floor((t - mzseconds) * 100);
913
- let mzmilisecondsStr = mzmiliseconds < 10 ? ('0' + mzmiliseconds) : mzmiliseconds.toString();
914
- let mzsecondsStr = mzseconds < 10 ? ('0' + mzseconds) : mzseconds.toString();
915
- let mzminutesStr = mzminutes < 10 ? ('0' + mzminutes) : mzminutes.toString();
916
- return `${mzminutesStr}:${mzsecondsStr}.${mzmilisecondsStr}`;
917
- }
918
- _setCurrentTime(t) {
919
- if (this.video.currentTime != t && this._updateTime) {
920
- this.video.currentTime = t;
921
- }
922
- this.controlsComponents.curTimeText?.set(this.timeToString(t));
923
- if (this.onSetTime) {
924
- this.onSetTime(t);
925
- }
926
- if (this.onChangeCurrent) {
927
- this.onChangeCurrent(t);
928
- }
929
- }
930
- _setStartTime(t) {
931
- this.startTime = this.video.currentTime = t;
932
- this.controlsComponents.trimStartText?.set(this.timeToString(t));
933
- if (this.onSetTime) {
934
- this.onSetTime(t);
935
- }
936
- if (this.onChangeStart) {
937
- this.onChangeStart(t);
938
- }
939
- }
940
- _setEndTime(t) {
941
- this.endTime = this.video.currentTime = t;
942
- this.controlsComponents.trimEndText?.set(this.timeToString(t));
943
- if (this.onSetTime) {
944
- this.onSetTime(t);
945
- }
946
- if (this.onChangeEnd) {
947
- this.onChangeEnd(t);
948
- }
949
- }
950
- getStartTime() {
951
- return this.startTime;
952
- }
953
- getEndTime() {
954
- return this.endTime;
955
- }
956
- getTrimedTimes() {
957
- return { start: this.startTime, end: this.endTime };
958
- }
959
- getCroppedArea() {
960
- return this.cropArea.getBoundingClientRect();
961
- }
962
- showCropArea() {
963
- this.crop = true;
964
- this.cropArea.classList.remove('hidden');
965
- this.controlsComponents.resetCropBtn?.root.classList.remove('hidden');
966
- const nodes = this.cropArea.parentElement?.childNodes ?? [];
967
- const rect = this.cropArea.getBoundingClientRect();
968
- for (let i = 0; i < nodes.length; i++) {
969
- const node = nodes[i];
970
- if (node == this.cropArea)
971
- continue;
972
- const rectEl = node.getBoundingClientRect();
973
- node.style.webkitMask = `linear-gradient(#000 0 0) ${rect.left - rectEl.left}px ${rect.top - rectEl.top}px / ${rect.width}px ${rect.height}px, linear-gradient(rgba(0, 0, 0, 0.3) 0 0)`;
974
- node.style.webkitMaskRepeat = 'no-repeat';
975
- }
976
- }
977
- hideCropArea() {
978
- this.crop = false;
979
- this.cropArea.classList.add('hidden');
980
- this.controlsComponents.resetCropBtn?.root.classList.add('hidden');
981
- const nodes = this.cropArea.parentElement?.childNodes ?? [];
982
- for (let i = 0; i < nodes.length; i++) {
983
- const node = nodes[i];
984
- if (node == this.cropArea)
985
- continue;
986
- node.style.webkitMask = '';
987
- node.style.webkitMaskRepeat = 'no-repeat';
988
- }
989
- }
990
- showControls() {
991
- this.controls = true;
992
- this.controlsArea.show();
993
- }
994
- hideControls() {
995
- this.controls = false;
996
- this.controlsArea.hide();
997
- }
998
- stopUpdates() {
999
- if (this.requestId) {
1000
- cancelAnimationFrame(this.requestId);
1001
- this.requestId = null;
1002
- }
1003
- }
1004
- unbind() {
1005
- this.stopUpdates();
1006
- this.video.pause();
1007
- this.playing = false;
1008
- this.controlsComponents.playBtn?.setState(false, true); // skip callback
1009
- this.video.src = '';
1010
- if (this.timebar) {
1011
- this.timebar.unbind();
1012
- }
1013
- window.removeEventListener('keyup', this.onKeyUp);
1014
- document.removeEventListener('mouseup', this._onCropMouseUp);
1015
- document.removeEventListener('mousemove', this._onCropMouseMove);
1016
- }
1017
- }
1018
- LX.VideoEditor = VideoEditor;
1019
-
1020
- export { TimeBar, VideoEditor };
1021
- //# sourceMappingURL=VideoEditor.js.map
1
+ // This is a generated file. Do not edit.
2
+ import { LX } from '../core/Namespace.js';
3
+
4
+ // VideoEditor.ts @evallsg
5
+ if (!LX) {
6
+ throw ('Missing LX namespace!');
7
+ }
8
+ LX.extensions.push('VideoEditor');
9
+ const vec2 = LX.vec2;
10
+ LX.Area;
11
+ LX.Panel;
12
+ /**
13
+ * @class TimeBar
14
+ */
15
+ class TimeBar {
16
+ static TIMEBAR_PLAY = 1;
17
+ static TIMEBAR_TRIM = 2;
18
+ static BACKGROUND_COLOR = LX.getCSSVariable('secondary');
19
+ static COLOR = LX.getCSSVariable('accent');
20
+ static ACTIVE_COLOR = LX.getCSSVariable('color-blue-400');
21
+ type = TimeBar.TIMEBAR_PLAY;
22
+ duration = 1.0;
23
+ canvas;
24
+ ctx;
25
+ options;
26
+ markerWidth = 8;
27
+ markerHeight;
28
+ offset;
29
+ lineWidth;
30
+ lineHeight;
31
+ linePosition;
32
+ startX;
33
+ endX;
34
+ currentX;
35
+ hovering;
36
+ dragging;
37
+ _onMouseUpListener;
38
+ _onMouseMoveListener;
39
+ _mouseDownCanvasRect = null;
40
+ updateTheme;
41
+ onChangeCurrent;
42
+ onChangeStart;
43
+ onChangeEnd;
44
+ onDraw;
45
+ onMouse;
46
+ constructor(area, type, options = {}) {
47
+ this.type = type ?? TimeBar.TIMEBAR_PLAY;
48
+ this.options = options ?? {};
49
+ this.duration = options.duration ?? this.duration;
50
+ // Create canvas
51
+ this.canvas = document.createElement('canvas');
52
+ this.canvas.style.borderRadius = '6px';
53
+ this.canvas.width = area.size[0];
54
+ this.canvas.height = area.size[1];
55
+ area.attach(this.canvas);
56
+ this.ctx = this.canvas.getContext('2d');
57
+ this.markerWidth = options.markerWidth ?? this.markerWidth;
58
+ this.markerHeight = (options.markerHeight ?? 0.5) * this.canvas.height;
59
+ const defaultOffset = this.markerWidth * 0.5 + 5;
60
+ if (typeof (options.offset) == 'number') {
61
+ this.offset = new vec2(options.offset, options.offset);
62
+ }
63
+ else if (Array.isArray(options.offset)) {
64
+ this.offset = new vec2(options.offset[0] ?? defaultOffset, options.offset[1] ?? defaultOffset);
65
+ }
66
+ else {
67
+ this.offset = new vec2(defaultOffset, defaultOffset);
68
+ }
69
+ // dimensions of line (not canvas)
70
+ this.lineWidth = this.canvas.width - this.offset.x * 2;
71
+ this.lineHeight = options.barHeight ?? 5;
72
+ this.linePosition = new vec2(this.offset.x, this.canvas.height * 0.5 - this.lineHeight * 0.5);
73
+ this.startX = this.linePosition.x;
74
+ this.endX = this.linePosition.x + this.lineWidth;
75
+ this.currentX = this.startX;
76
+ this._draw();
77
+ function updateTheme() {
78
+ TimeBar.BACKGROUND_COLOR = LX.getCSSVariable('secondary');
79
+ TimeBar.COLOR = LX.getCSSVariable('accent');
80
+ TimeBar.ACTIVE_COLOR = LX.getCSSVariable('color-blue-400');
81
+ }
82
+ this.updateTheme = updateTheme.bind(this);
83
+ LX.addSignal('@on_new_color_scheme', this.updateTheme);
84
+ this.updateTheme();
85
+ // prepare event listeners' functions
86
+ this._onMouseUpListener = this.onMouseUp.bind(this);
87
+ this._onMouseMoveListener = this.onMouseMove.bind(this);
88
+ this.canvas.onmousedown = (e) => this.onMouseDown(e);
89
+ this.canvas.onmousemove = (e) => {
90
+ if (this.dragging)
91
+ return; // already handled by _onMouseMoveListener
92
+ this.onMouseMove(e);
93
+ };
94
+ }
95
+ unbind() {
96
+ removeEventListener('mousemove', this._onMouseMoveListener);
97
+ removeEventListener('mouseup', this._onMouseUpListener);
98
+ }
99
+ setDuration(duration) {
100
+ this.duration = duration;
101
+ }
102
+ xToTime(x) {
103
+ return ((x - this.offset.x) / (this.lineWidth)) * this.duration;
104
+ }
105
+ timeToX(time) {
106
+ return (time / this.duration) * (this.lineWidth) + this.offset.x;
107
+ }
108
+ setCurrentTime(time) {
109
+ this.currentX = this.timeToX(time);
110
+ this.onSetCurrentValue(this.currentX);
111
+ }
112
+ setStartTime(time) {
113
+ this.startX = this.timeToX(time);
114
+ this.onSetStartValue(this.startX);
115
+ }
116
+ setEndTime(time) {
117
+ this.endX = this.timeToX(time);
118
+ this.onSetEndValue(this.endX);
119
+ }
120
+ onSetCurrentValue(x) {
121
+ this.update(x);
122
+ const t = this.xToTime(x);
123
+ if (this.onChangeCurrent) {
124
+ this.onChangeCurrent(t);
125
+ }
126
+ }
127
+ onSetStartValue(x) {
128
+ this.update(x);
129
+ const t = this.xToTime(x);
130
+ if (this.onChangeStart) {
131
+ this.onChangeStart(t);
132
+ }
133
+ }
134
+ onSetEndValue(x) {
135
+ this.update(x);
136
+ const t = this.xToTime(x);
137
+ if (this.onChangeEnd) {
138
+ this.onChangeEnd(t);
139
+ }
140
+ }
141
+ _draw() {
142
+ const ctx = this.ctx;
143
+ if (!ctx)
144
+ return;
145
+ ctx.save();
146
+ ctx.fillStyle = TimeBar.BACKGROUND_COLOR;
147
+ ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
148
+ ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
149
+ // Draw background timeline
150
+ ctx.fillStyle = TimeBar.COLOR;
151
+ ctx.fillRect(this.linePosition.x, this.linePosition.y, this.lineWidth, this.lineHeight);
152
+ // Draw background trimed timeline
153
+ ctx.fillStyle = TimeBar.ACTIVE_COLOR;
154
+ ctx.fillRect(this.startX, this.linePosition.y, this.endX - this.startX, this.lineHeight);
155
+ ctx.restore();
156
+ // Min-Max time markers
157
+ this._drawTrimMarker('start', this.startX, { color: null, fillColor: TimeBar.ACTIVE_COLOR || '#5f88c9' });
158
+ this._drawTrimMarker('end', this.endX, { color: null, fillColor: TimeBar.ACTIVE_COLOR || '#5f88c9' });
159
+ this._drawTimeMarker('current', this.currentX, { color: '#e5e5e5', fillColor: TimeBar.ACTIVE_COLOR || '#5f88c9', width: this.markerWidth });
160
+ if (this.onDraw) {
161
+ this.onDraw();
162
+ }
163
+ }
164
+ _drawTrimMarker(name, x, options = {}) {
165
+ const w = this.markerWidth;
166
+ const h = this.markerHeight;
167
+ const y = this.canvas.height * 0.5 - h * 0.5;
168
+ const ctx = this.ctx;
169
+ if (!ctx)
170
+ return;
171
+ // Shadow
172
+ if (this.hovering == name) {
173
+ ctx.shadowColor = 'white';
174
+ ctx.shadowBlur = 2;
175
+ }
176
+ ctx.globalAlpha = 1;
177
+ ctx.fillStyle = ctx.strokeStyle = options.fillColor || '#111'; // "#FFF";
178
+ ctx.beginPath();
179
+ ctx.roundRect(x - w * 0.5, y, w, h, 2);
180
+ ctx.fill();
181
+ ctx.fillStyle = ctx.strokeStyle = options.fillColor || '#111'; // "#FFF";
182
+ ctx.strokeStyle = 'white';
183
+ ctx.beginPath();
184
+ ctx.lineWidth = 2;
185
+ ctx.moveTo(x, y + 4);
186
+ ctx.lineTo(x, y + h - 4);
187
+ ctx.stroke();
188
+ ctx.shadowBlur = 0;
189
+ }
190
+ _drawTimeMarker(name, x, options = {}) {
191
+ let y = this.offset.y;
192
+ const w = options.width ? options.width : (this.dragging == name ? 6 : 4);
193
+ this.canvas.height - this.offset.y * 2;
194
+ let ctx = this.ctx;
195
+ if (!ctx)
196
+ return;
197
+ ctx.globalAlpha = 1;
198
+ ctx.fillStyle = ctx.strokeStyle = options.fillColor || '#111'; // "#FFF";
199
+ // Shadow
200
+ if (this.hovering == name) {
201
+ ctx.shadowColor = 'white';
202
+ ctx.shadowBlur = 2;
203
+ }
204
+ // Current time line
205
+ ctx.fillStyle = ctx.strokeStyle = 'white';
206
+ ctx.beginPath();
207
+ ctx.moveTo(x, y);
208
+ ctx.lineTo(x, this.linePosition.y + this.lineHeight * 0.5);
209
+ ctx.stroke();
210
+ ctx.closePath();
211
+ ctx.fillStyle = ctx.strokeStyle = options.fillColor || '#111'; // "#FFF";
212
+ // Current time ball grab
213
+ ctx.fillStyle = options.fillColor || '#e5e5e5';
214
+ ctx.beginPath();
215
+ ctx.roundRect(x - w * 0.5, y - w * 0.5, w, w, 5);
216
+ ctx.fill();
217
+ ctx.shadowBlur = 0;
218
+ }
219
+ update(x) {
220
+ this.currentX = Math.min(Math.max(this.startX, x), this.endX);
221
+ this._draw();
222
+ }
223
+ onMouseDown(e) {
224
+ if (this.onMouse) {
225
+ this.onMouse(e);
226
+ }
227
+ e.preventDefault();
228
+ if (!this.canvas || e.target != this.canvas || e.cancelBubble) {
229
+ return;
230
+ }
231
+ const canvas = this.canvas;
232
+ // Process mouse
233
+ const x = e.offsetX;
234
+ const y = e.offsetY;
235
+ // Check if some marker is clicked
236
+ const threshold = this.markerWidth;
237
+ const startDist = Math.abs(this.startX - x);
238
+ const endDist = Math.abs(this.endX - x);
239
+ // grab trim markers only from the bottom
240
+ if ((startDist < threshold || endDist < threshold) && this.linePosition.y < y) {
241
+ this.dragging = (startDist < endDist || x < this.startX) ? 'start' : 'end';
242
+ canvas.style.cursor = 'grabbing';
243
+ }
244
+ else {
245
+ this.dragging = 'current';
246
+ canvas.style.cursor = 'grabbing';
247
+ if (x < this.startX) {
248
+ this.currentX = this.startX;
249
+ }
250
+ else if (x > this.endX) {
251
+ this.currentX = this.endX;
252
+ }
253
+ else {
254
+ this.currentX = x;
255
+ }
256
+ this.onSetCurrentValue(this.currentX);
257
+ }
258
+ this._mouseDownCanvasRect = canvas.getBoundingClientRect(); // cache this to avoid stalls during mousemove
259
+ window.addEventListener('mousemove', this._onMouseMoveListener);
260
+ window.addEventListener('mouseup', this._onMouseUpListener);
261
+ this._draw();
262
+ }
263
+ onMouseUp(e) {
264
+ window.removeEventListener('mousemove', this._onMouseMoveListener);
265
+ window.removeEventListener('mouseup', this._onMouseUpListener);
266
+ if (this.onMouse) {
267
+ this.onMouse(e);
268
+ }
269
+ e.preventDefault();
270
+ delete this.dragging;
271
+ delete this.hovering;
272
+ if (!this.canvas || e.cancelBubble) {
273
+ return;
274
+ }
275
+ const canvas = this.canvas;
276
+ canvas.style.cursor = 'default';
277
+ }
278
+ onMouseMove(e) {
279
+ if (this.onMouse) {
280
+ this.onMouse(e);
281
+ }
282
+ if (!this.canvas || e.cancelBubble) {
283
+ return;
284
+ }
285
+ e.preventDefault();
286
+ const canvas = this.canvas;
287
+ // Process mouse
288
+ const x = e.target == canvas ? e.offsetX : (e.clientX - this._mouseDownCanvasRect.left);
289
+ e.target == canvas ? e.offsetY : (e.clientY - this._mouseDownCanvasRect.top);
290
+ if (this.dragging) {
291
+ switch (this.dragging) {
292
+ case 'start':
293
+ this.startX = Math.max(this.linePosition.x, Math.min(this.endX, x));
294
+ this.currentX = this.startX;
295
+ this.onSetStartValue(this.startX);
296
+ break;
297
+ case 'end':
298
+ this.endX = Math.max(this.startX, Math.min(this.linePosition.x + this.lineWidth, x));
299
+ this.currentX = this.endX;
300
+ this.onSetEndValue(this.endX);
301
+ break;
302
+ default:
303
+ this.currentX = Math.max(this.startX, Math.min(this.endX, x));
304
+ break;
305
+ }
306
+ this.onSetCurrentValue(this.currentX);
307
+ }
308
+ else {
309
+ const threshold = this.markerWidth * 0.5;
310
+ if (Math.abs(this.startX - x) < threshold) {
311
+ this.hovering = 'start';
312
+ canvas.style.cursor = 'grab';
313
+ }
314
+ else if (Math.abs(this.endX - x) < threshold) {
315
+ this.hovering = 'end';
316
+ canvas.style.cursor = 'grab';
317
+ }
318
+ else if (Math.abs(this.currentX - x) < threshold) {
319
+ this.hovering = 'current';
320
+ canvas.style.cursor = 'grab';
321
+ }
322
+ else {
323
+ delete this.hovering;
324
+ canvas.style.cursor = 'default';
325
+ }
326
+ }
327
+ this._draw();
328
+ }
329
+ resize(size) {
330
+ this.canvas.width = Math.max(0, size[0]);
331
+ this.canvas.height = Math.max(0, size[1]);
332
+ this.markerHeight = (this.options.markerHeight ?? 0.5) * this.canvas.height;
333
+ let newWidth = size[0] - this.offset.x * 2;
334
+ newWidth = newWidth < 0.00001 ? 0.00001 : newWidth; // actual width of the line = canvas.width - offsetleft - offsetRight
335
+ const startRatio = (this.startX - this.offset.x) / this.lineWidth;
336
+ const currentRatio = (this.currentX - this.offset.x) / this.lineWidth;
337
+ const endRatio = (this.endX - this.offset.x) / this.lineWidth;
338
+ this.lineWidth = newWidth;
339
+ this.linePosition.x = this.offset.x;
340
+ this.linePosition.y = this.canvas.height * 0.5 - this.lineHeight * 0.5;
341
+ this.startX = Math.min(Math.max(newWidth * startRatio, 0), newWidth) + this.offset.x;
342
+ this.currentX = Math.min(Math.max(newWidth * currentRatio, 0), newWidth) + this.offset.x;
343
+ this.endX = Math.min(Math.max(newWidth * endRatio, 0), newWidth) + this.offset.x;
344
+ this._draw();
345
+ }
346
+ }
347
+ LX.TimeBar = TimeBar;
348
+ /**
349
+ * @class VideoEditor
350
+ */
351
+ class VideoEditor {
352
+ static CROP_HANDLE_L = 0x01;
353
+ static CROP_HANDLE_R = 0x02;
354
+ static CROP_HANDLE_T = 0x04;
355
+ static CROP_HANDLE_B = 0x08;
356
+ static CROP_HANDLE_TL = VideoEditor.CROP_HANDLE_L | VideoEditor.CROP_HANDLE_T;
357
+ static CROP_HANDLE_BL = VideoEditor.CROP_HANDLE_L | VideoEditor.CROP_HANDLE_B;
358
+ static CROP_HANDLE_TR = VideoEditor.CROP_HANDLE_R | VideoEditor.CROP_HANDLE_T;
359
+ static CROP_HANDLE_BR = VideoEditor.CROP_HANDLE_R | VideoEditor.CROP_HANDLE_B;
360
+ options = {};
361
+ playing = false;
362
+ videoReady = false;
363
+ controls = true;
364
+ speed = 1.0;
365
+ startTime = 0.0;
366
+ endTime = 0.0;
367
+ requestId;
368
+ video;
369
+ loop = false;
370
+ isDragging = false;
371
+ isResizing = null; // holds the HTMLElement of the crop handle, if resizing
372
+ crop = false;
373
+ dragOffsetX = 0.0;
374
+ dragOffsetY = 0.0;
375
+ timebar = null;
376
+ mainArea;
377
+ cropArea; // HTMLElement with normCoord attribute;
378
+ videoArea;
379
+ controlsArea;
380
+ controlsComponents;
381
+ onChangeCurrent;
382
+ onChangeStart;
383
+ onChangeEnd;
384
+ onKeyUp;
385
+ onSetTime;
386
+ onVideoLoaded;
387
+ onResize;
388
+ onCropArea;
389
+ onChangeSpeed;
390
+ onChangeState;
391
+ onChangeLoop;
392
+ _updateTime = true;
393
+ _onCropMouseUp;
394
+ _onCropMouseMove;
395
+ resize = null;
396
+ resizeControls = null;
397
+ resizeVideo = null;
398
+ constructor(area, options = {}) {
399
+ this.options = options ?? {};
400
+ const controlsOptions = this.options.controlsLayout ?? {};
401
+ this.speed = options.speed ?? this.speed;
402
+ this.mainArea = area;
403
+ let videoArea = null;
404
+ let controlsArea = null;
405
+ if (options.controlsArea) {
406
+ videoArea = area;
407
+ controlsArea = options.controlsArea;
408
+ }
409
+ else {
410
+ [videoArea, controlsArea] = area.split({ type: 'vertical',
411
+ sizes: [controlsOptions.height ? `calc(100% - ${controlsOptions.height})` : '85%', null], minimizable: false, resize: false });
412
+ }
413
+ controlsArea.root.classList.add('lexconstrolsarea');
414
+ this.cropArea = document.createElement('div');
415
+ this.cropArea.id = 'cropArea';
416
+ this.cropArea.className = 'resize-area hidden';
417
+ this.cropArea.normCoords = { x: 0, y: 0, w: 1, h: 1 };
418
+ const flags = 0x0f;
419
+ this.setCropAreaHandles(flags);
420
+ this.crop = options.crop;
421
+ this.dragOffsetX = 0;
422
+ this.dragOffsetY = 0;
423
+ // Create video element and load it
424
+ let video = this.video = options.video ?? document.createElement('video');
425
+ this.loop = options.loop ?? this.loop;
426
+ if (options.src) {
427
+ this.video.src = options.src;
428
+ this.loadVideo(options);
429
+ }
430
+ if (options.videoArea) {
431
+ options.videoArea.root.classList.add('lexvideoeditor');
432
+ options.videoArea.root.style.position = 'relative';
433
+ options.videoArea.attach(this.cropArea);
434
+ videoArea.attach(options.videoArea);
435
+ }
436
+ else {
437
+ videoArea.attach(video);
438
+ videoArea.attach(this.cropArea);
439
+ videoArea.root.classList.add('lexvideoeditor');
440
+ }
441
+ videoArea.root.style.position = 'relative';
442
+ this.videoArea = videoArea;
443
+ this.controlsArea = controlsArea;
444
+ this.controlsComponents = {
445
+ timebar: null,
446
+ playBtn: null,
447
+ speedBtn: null,
448
+ loopBtn: null,
449
+ trimStartText: null,
450
+ trimEndText: null,
451
+ curTimeText: null,
452
+ resetCropBtn: null
453
+ };
454
+ this.createControls();
455
+ this.resizeVideo = () => {
456
+ this.moveCropArea(this.cropArea.normCoords.x, this.cropArea.normCoords.y, true);
457
+ this.resizeCropArea(this.cropArea.normCoords.w, this.cropArea.normCoords.h, true);
458
+ if (this.onResize) {
459
+ this.onResize([videoArea.root.clientWidth, videoArea.root.clientHeight]);
460
+ }
461
+ };
462
+ this.resize = () => {
463
+ this.resizeVideo();
464
+ this.resizeControls();
465
+ };
466
+ area.onresize = this.resize.bind(this);
467
+ window.addEventListener('resize', area.onresize);
468
+ this.onKeyUp = (e) => {
469
+ if (this.controls && e.key == ' ') {
470
+ e.preventDefault();
471
+ e.stopPropagation();
472
+ // do not skip callback
473
+ this.controlsComponents.playBtn?.setState(!this.playing, false);
474
+ }
475
+ };
476
+ window.addEventListener('keyup', this.onKeyUp);
477
+ this._onCropMouseUp = (event) => {
478
+ event.preventDefault();
479
+ event.stopPropagation();
480
+ if ((this.isDragging || this.isResizing) && this.onCropArea) {
481
+ this.onCropArea(this.getCroppedArea());
482
+ }
483
+ this.isDragging = false;
484
+ this.isResizing = false;
485
+ document.removeEventListener('mouseup', this._onCropMouseUp); // self destroy. Added during mouseDown on cropArea and handles
486
+ document.removeEventListener('mousemove', this._onCropMouseMove); // self destroy. Added during mouseDown on cropArea and handles
487
+ };
488
+ this._onCropMouseMove = (event) => {
489
+ window.getSelection()?.removeAllRanges();
490
+ event.preventDefault();
491
+ event.stopPropagation();
492
+ if (this.isResizing) {
493
+ const rectCrop = this.cropArea.getBoundingClientRect();
494
+ const rectVideo = this.video.getBoundingClientRect();
495
+ const mov = this.isResizing.movement;
496
+ let x = rectCrop.left, y = rectCrop.top, w = rectCrop.width, h = rectCrop.height;
497
+ if (mov & VideoEditor.CROP_HANDLE_L) {
498
+ let mouseX = Math.min(rectCrop.right - 4, Math.max(rectVideo.left, event.clientX)); // -4 because of border
499
+ w = rectCrop.left + rectCrop.width - mouseX;
500
+ x = mouseX;
501
+ if (mouseX < rectCrop.left) {
502
+ this.moveCropArea(x, y, false);
503
+ this.resizeCropArea(w, h, false);
504
+ }
505
+ else {
506
+ this.resizeCropArea(w, h, false);
507
+ this.moveCropArea(x, y, false);
508
+ }
509
+ }
510
+ if (mov & VideoEditor.CROP_HANDLE_R) {
511
+ w = event.clientX - rectCrop.left;
512
+ this.resizeCropArea(w, h, false);
513
+ }
514
+ if (mov & VideoEditor.CROP_HANDLE_T) {
515
+ const mouseY = Math.min(rectCrop.bottom - 4, Math.max(rectVideo.top, event.clientY));
516
+ h = rectCrop.top + rectCrop.height - mouseY;
517
+ y = mouseY;
518
+ if (mouseY < rectCrop.top) {
519
+ this.moveCropArea(x, y, false);
520
+ this.resizeCropArea(w, h, false);
521
+ }
522
+ else {
523
+ this.resizeCropArea(w, h, false);
524
+ this.moveCropArea(x, y, false);
525
+ }
526
+ }
527
+ if (mov & VideoEditor.CROP_HANDLE_B) {
528
+ h = event.clientY - rectCrop.top;
529
+ this.resizeCropArea(w, h, false);
530
+ }
531
+ }
532
+ if (this.isDragging) {
533
+ this.moveCropArea(event.clientX - this.dragOffsetX, event.clientY - this.dragOffsetY, false);
534
+ }
535
+ };
536
+ this.cropArea.addEventListener('mousedown', (e) => {
537
+ if (e.target === this.cropArea) {
538
+ const rect = this.cropArea.getBoundingClientRect();
539
+ this.isDragging = true;
540
+ this.dragOffsetX = e.clientX - rect.left;
541
+ this.dragOffsetY = e.clientY - rect.top;
542
+ document.addEventListener('mouseup', this._onCropMouseUp);
543
+ document.addEventListener('mousemove', this._onCropMouseMove);
544
+ }
545
+ });
546
+ this.onChangeStart = null;
547
+ this.onChangeEnd = null;
548
+ }
549
+ createControls(controlsLayoutOptions = null) {
550
+ const controlsArea = this.controlsArea;
551
+ if (controlsLayoutOptions) {
552
+ this.options.controlsLayout = controlsLayoutOptions;
553
+ }
554
+ const controlsOptions = this.options.controlsLayout ?? {};
555
+ // clear area. Signals are not cleared !!! (not a problem if there are no signals)
556
+ while (controlsArea.root.children.length) {
557
+ controlsArea.root.children[0].remove();
558
+ }
559
+ controlsArea.sections.length = 0;
560
+ // start trimming text
561
+ this.controlsComponents.trimStartText = new LX.TextInput(null, this.timeToString(this.startTime), null, { width: '100px',
562
+ title: 'Trimmed Start Time', disabled: true, inputClass: 'bg-none' });
563
+ this.controlsComponents.trimEndText = new LX.TextInput(null, this.timeToString(this.endTime), null, { width: '100px',
564
+ title: 'Trimmed End Time', disabled: true, inputClass: 'bg-none' });
565
+ this.controlsComponents.curTimeText = new LX.TextInput(null, this.video.currentTime, null, { title: 'Current Time', float: 'center',
566
+ disabled: true, inputClass: 'bg-none' });
567
+ // reset crop area
568
+ this.controlsComponents.resetCropBtn = new LX.Button('ResetCrop', null, (v) => {
569
+ this.moveCropArea(0, 0, true);
570
+ this.resizeCropArea(1, 1, true);
571
+ if (this.onCropArea) {
572
+ this.onCropArea(this.getCroppedArea());
573
+ }
574
+ }, { width: '40px', title: 'Reset Crop Area', icon: 'Crop@solid', hideName: true,
575
+ className: 'justify-center' + (this.crop ? '' : ' hidden') });
576
+ // play button
577
+ this.controlsComponents.playBtn = new LX.Button('Play', '', (v) => {
578
+ this.playing = v;
579
+ if (this.playing) {
580
+ if (this.video.currentTime + 0.000001 >= this.endTime) {
581
+ this.video.currentTime = this.startTime;
582
+ }
583
+ this.video.play();
584
+ }
585
+ else {
586
+ this.video.pause();
587
+ }
588
+ if (this.onChangeState) {
589
+ this.onChangeState(v);
590
+ }
591
+ }, { width: '40px', title: 'Play/Pause', icon: 'Play@solid', swap: 'Pause@solid', hideName: true, className: 'justify-center' });
592
+ this.controlsComponents.playBtn.setState(this.playing, true);
593
+ // speed button
594
+ this.controlsComponents.speedBtn = new LX.Button('Speed', '', (v, e) => {
595
+ const panel = new LX.Panel();
596
+ panel.addRange('Speed', this.speed, (v) => {
597
+ this.speed = v;
598
+ this.video.playbackRate = v;
599
+ if (this.onChangeSpeed) {
600
+ this.onChangeSpeed(v);
601
+ }
602
+ }, { min: 0, max: 2.5, step: 0.01, hideName: true });
603
+ new LX.Popover(e.target, [panel], { align: 'start', side: 'top', sideOffset: 12 });
604
+ }, { width: '40px', title: 'Speed', hideName: true, icon: 'Timer@solid', className: 'justify-center' });
605
+ // loop button
606
+ this.controlsComponents.loopBtn = new LX.Button('', 'Loop', (v) => {
607
+ this.loop = v;
608
+ if (this.onChangeLoop) {
609
+ this.onChangeLoop(v);
610
+ }
611
+ }, { width: '40px', hideName: true, title: 'Loop', icon: 'Repeat@solid', className: `justify-center`, selectable: true,
612
+ selected: this.loop });
613
+ let timeBarArea = null;
614
+ if (typeof (controlsOptions.type) == 'function') {
615
+ timeBarArea = controlsOptions.type;
616
+ }
617
+ else if (controlsOptions.type == 1) {
618
+ timeBarArea = this._createControlsLayout_1();
619
+ }
620
+ else {
621
+ timeBarArea = this._createControlsLayout_0();
622
+ }
623
+ if (this.timebar) {
624
+ this.timebar.unbind();
625
+ }
626
+ this.timebar = this.controlsComponents.timebar = new TimeBar(timeBarArea, TimeBar.TIMEBAR_TRIM, { offset: [12, null] });
627
+ this.timebar.onChangeCurrent = this._setCurrentTime.bind(this);
628
+ this.timebar.onChangeStart = this._setStartTime.bind(this);
629
+ this.timebar.onChangeEnd = this._setEndTime.bind(this);
630
+ let duration = 1;
631
+ if (this.video.duration !== Infinity && !isNaN(this.video.duration)) {
632
+ duration = this.video.duration;
633
+ }
634
+ this.timebar.setDuration(duration);
635
+ this.timebar.setEndTime(this.endTime);
636
+ this.timebar.setStartTime(this.startTime);
637
+ this.timebar.setCurrentTime(this.startTime);
638
+ this.resizeControls();
639
+ }
640
+ /**
641
+ * Creates the areas where components will be.
642
+ * Attaches all (desired) components of controlsComponents except the timebar
643
+ * @returns {Area} for the timebar
644
+ * Layout:
645
+ * |--------------------------timebar--------------------------|
646
+ * play speed loop resetCrop curTime trimStart / trimEnd
647
+ */
648
+ _createControlsLayout_1() {
649
+ const controlsArea = this.controlsArea;
650
+ const options = this.options.controlsLayout ?? {};
651
+ // Create playing timeline area and attach panels
652
+ let [timeBarArea, bottomArea] = controlsArea.split({ type: 'vertical', sizes: [options.l1TimelineHeight ?? '50%', null],
653
+ minimizable: false, resize: false });
654
+ bottomArea.root.classList.add('relative');
655
+ let separator = document.createElement('p');
656
+ separator.style.alignContent = 'center';
657
+ separator.innerText = '/';
658
+ let trimDiv = LX.makeContainer(['fit-content', '100%'], 'relative flex flex-row pb-2', null, bottomArea, { float: 'right' });
659
+ trimDiv.appendChild(this.controlsComponents.trimStartText.root);
660
+ trimDiv.appendChild(separator);
661
+ trimDiv.appendChild(this.controlsComponents.trimEndText.root);
662
+ this.controlsComponents.trimStartText.root.querySelector('input').classList.add('text-end');
663
+ this.controlsComponents.trimStartText.root.classList.add('top-0', 'bottom-0');
664
+ this.controlsComponents.trimEndText.root.classList.add('top-0', 'bottom-0');
665
+ // current time
666
+ let curTimeDiv = LX.makeContainer(['100%', '100%'], 'absolute top-0 left-0 flex flex-row justify-center items-center pb-2', null, bottomArea, {});
667
+ curTimeDiv.appendChild(this.controlsComponents.curTimeText.root);
668
+ // Buttons
669
+ const buttonsPanel = bottomArea.addPanel({ className: 'absolute top-0 left-0 flex flex-row pl-4 pr-4 pt-1 pb-2' });
670
+ buttonsPanel.root.classList.remove('pad-md');
671
+ buttonsPanel._attachComponent(this.controlsComponents.playBtn);
672
+ buttonsPanel._attachComponent(this.controlsComponents.speedBtn);
673
+ buttonsPanel._attachComponent(this.controlsComponents.loopBtn);
674
+ buttonsPanel._attachComponent(this.controlsComponents.resetCropBtn);
675
+ this.controlsComponents.playBtn.root.classList.add('pl-0');
676
+ this.controlsComponents.resetCropBtn.root.classList.add('pr-0');
677
+ // timebar
678
+ timeBarArea.root.classList.add('p-4', 'pb-0');
679
+ this.resizeControls = () => {
680
+ const style = getComputedStyle(timeBarArea.root);
681
+ let pleft = parseFloat(style.paddingLeft);
682
+ let pright = parseFloat(style.paddingRight);
683
+ let ptop = parseFloat(style.paddingTop);
684
+ let pbot = parseFloat(style.paddingBottom);
685
+ // assuming timeBarArea will not overflow
686
+ this.timebar.resize([timeBarArea.root.clientWidth - pleft - pright, timeBarArea.root.clientHeight - ptop - pbot]);
687
+ };
688
+ return timeBarArea;
689
+ }
690
+ /**
691
+ * Creates the areas where components will be.
692
+ * Attaches all (desired) components of controlsComponents except the timebar
693
+ * @returns {Area} for the timebar
694
+ * Layout:
695
+ * curTime
696
+ * play speed loop trimStart |---timebar---| trimend
697
+ */
698
+ _createControlsLayout_0() {
699
+ const controlsArea = this.controlsArea;
700
+ // Create playing timeline area and attach panels
701
+ let [topArea, bottomArea] = controlsArea.split({ type: 'vertical', sizes: ['50%', null], minimizable: false, resize: false });
702
+ bottomArea.setSize([bottomArea.size[0], 40]);
703
+ let [leftArea, controlsRight] = bottomArea.split({ type: 'horizontal', sizes: ['92%', null], minimizable: false, resize: false });
704
+ let [controlsLeft, timeBarArea] = leftArea.split({ type: 'horizontal', sizes: ['10%', null], minimizable: false, resize: false });
705
+ const controlsCurrentPanel = topArea.addPanel({ className: 'flex' });
706
+ controlsCurrentPanel._attachComponent(this.controlsComponents.curTimeText);
707
+ // Create controls panel (play/pause button and start time)
708
+ controlsLeft.root.classList.add('min-w-fit');
709
+ const controlsPanelLeft = controlsLeft.addPanel({ className: 'lexcontrolspanel p-0 pl-2' });
710
+ controlsPanelLeft.root.classList.remove('pad-md');
711
+ controlsPanelLeft.sameLine();
712
+ controlsPanelLeft._attachComponent(this.controlsComponents.playBtn);
713
+ controlsPanelLeft._attachComponent(this.controlsComponents.speedBtn);
714
+ controlsPanelLeft._attachComponent(this.controlsComponents.loopBtn);
715
+ controlsPanelLeft._attachComponent(this.controlsComponents.trimStartText);
716
+ controlsPanelLeft.endLine();
717
+ // Create right controls panel (end time)
718
+ controlsRight.root.classList.add('min-w-fit');
719
+ const controlsPanelRight = controlsRight.addPanel({ className: 'lexcontrolspanel p-0' });
720
+ controlsPanelRight.root.classList.remove('pad-md');
721
+ controlsPanelRight._attachComponent(this.controlsComponents.trimEndText);
722
+ this.resizeControls = () => {
723
+ bottomArea.setSize([this.controlsArea.root.clientWidth, 40]);
724
+ let availableWidth = this.controlsArea.root.clientWidth - controlsLeft.root.clientWidth
725
+ - controlsRight.root.clientWidth;
726
+ this.timebar.resize([availableWidth, timeBarArea.root.clientHeight]);
727
+ };
728
+ return timeBarArea;
729
+ }
730
+ setCropAreaHandles(flags) {
731
+ // remove existing resizer handles
732
+ const resizers = this.cropArea.getElementsByClassName('resize-handle');
733
+ for (let i = resizers.length - 1; i > -1; --i) {
734
+ resizers[i].remove();
735
+ }
736
+ const buildResizer = (className, movement) => {
737
+ const handle = document.createElement('div');
738
+ handle.className = ' resize-handle ' + className;
739
+ handle.movement = movement;
740
+ if (this.options.handleStyle) {
741
+ Object.assign(handle.style, this.options.handleStyle);
742
+ }
743
+ this.cropArea.append(handle);
744
+ handle.addEventListener('mousedown', (e) => {
745
+ e.stopPropagation();
746
+ e.preventDefault();
747
+ this.isResizing = handle;
748
+ document.addEventListener('mouseup', this._onCropMouseUp);
749
+ document.addEventListener('mousemove', this._onCropMouseMove);
750
+ });
751
+ };
752
+ if (flags & VideoEditor.CROP_HANDLE_L)
753
+ buildResizer('l', VideoEditor.CROP_HANDLE_L);
754
+ if (flags & VideoEditor.CROP_HANDLE_R)
755
+ buildResizer('r', VideoEditor.CROP_HANDLE_R);
756
+ if (flags & VideoEditor.CROP_HANDLE_T)
757
+ buildResizer('t', VideoEditor.CROP_HANDLE_T);
758
+ if (flags & VideoEditor.CROP_HANDLE_B)
759
+ buildResizer('b', VideoEditor.CROP_HANDLE_B);
760
+ if ((flags & VideoEditor.CROP_HANDLE_TL) == VideoEditor.CROP_HANDLE_TL) {
761
+ buildResizer('tl', VideoEditor.CROP_HANDLE_TL);
762
+ }
763
+ if ((flags & VideoEditor.CROP_HANDLE_BL) == VideoEditor.CROP_HANDLE_BL) {
764
+ buildResizer('bl', VideoEditor.CROP_HANDLE_BL);
765
+ }
766
+ if ((flags & VideoEditor.CROP_HANDLE_TR) == VideoEditor.CROP_HANDLE_TR) {
767
+ buildResizer('tr', VideoEditor.CROP_HANDLE_TR);
768
+ }
769
+ if ((flags & VideoEditor.CROP_HANDLE_BR) == VideoEditor.CROP_HANDLE_BR) {
770
+ buildResizer('br', VideoEditor.CROP_HANDLE_BR);
771
+ }
772
+ }
773
+ resizeCropArea(sx, sy, isNormalized = true) {
774
+ const rectVideo = this.video.getBoundingClientRect();
775
+ if (!isNormalized) {
776
+ sx = (rectVideo.width) ? (sx / rectVideo.width) : 1;
777
+ sy = (rectVideo.height) ? (sy / rectVideo.height) : 1;
778
+ }
779
+ sx = Math.min(1 - this.cropArea.normCoords.x, Math.max(0, sx));
780
+ sy = Math.min(1 - this.cropArea.normCoords.y, Math.max(0, sy));
781
+ this.cropArea.normCoords.w = sx;
782
+ this.cropArea.normCoords.h = sy;
783
+ const widthPx = rectVideo.width * sx;
784
+ const heightPx = rectVideo.height * sy;
785
+ const xPx = rectVideo.width * this.cropArea.normCoords.x + rectVideo.left;
786
+ const yPx = rectVideo.height * this.cropArea.normCoords.y + rectVideo.top;
787
+ if (!this.cropArea.classList.contains('hidden')) {
788
+ const nodes = this.cropArea.parentElement.childNodes;
789
+ for (let i = 0; i < nodes.length; i++) {
790
+ if (nodes[i] != this.cropArea) {
791
+ const rectEl = nodes[i].getBoundingClientRect();
792
+ nodes[i].style.webkitMask = `linear-gradient(#000 0 0) ${xPx - rectEl.left}px ${yPx - rectEl.top}px / ${widthPx}px ${heightPx}px, linear-gradient(rgba(0, 0, 0, 0.3) 0 0)`;
793
+ nodes[i].style.webkitMaskRepeat = 'no-repeat';
794
+ }
795
+ }
796
+ }
797
+ this.cropArea.style.width = widthPx + 'px';
798
+ this.cropArea.style.height = heightPx + 'px';
799
+ }
800
+ // screen pixel (event.clientX) or video normalized (0 is top left of video, 1 bot right)
801
+ moveCropArea(x, y, isNormalized = true) {
802
+ const rectVideo = this.video.getBoundingClientRect();
803
+ if (!isNormalized) {
804
+ x = (rectVideo.width) ? ((x - rectVideo.left) / rectVideo.width) : 0;
805
+ y = (rectVideo.height) ? ((y - rectVideo.top) / rectVideo.height) : 0;
806
+ }
807
+ x = Math.max(0, Math.min(1 - this.cropArea.normCoords.w, x));
808
+ y = Math.max(0, Math.min(1 - this.cropArea.normCoords.h, y));
809
+ this.cropArea.normCoords.x = x;
810
+ this.cropArea.normCoords.y = y;
811
+ const xPx = rectVideo.width * x + rectVideo.left;
812
+ const yPx = rectVideo.height * y + rectVideo.top;
813
+ const widthPx = rectVideo.width * this.cropArea.normCoords.w;
814
+ const heightPx = rectVideo.height * this.cropArea.normCoords.h;
815
+ if (!this.cropArea.classList.contains('hidden')) {
816
+ const nodes = this.cropArea.parentElement.childNodes;
817
+ for (let i = 0; i < nodes.length; i++) {
818
+ if (nodes[i] != this.cropArea) {
819
+ const rectEl = nodes[i].getBoundingClientRect();
820
+ nodes[i].style.webkitMask = `linear-gradient(#000 0 0) ${xPx - rectEl.left}px ${yPx - rectEl.top}px / ${widthPx}px ${heightPx}px, linear-gradient(rgba(0, 0, 0, 0.3) 0 0)`;
821
+ nodes[i].style.webkitMaskRepeat = 'no-repeat';
822
+ }
823
+ }
824
+ }
825
+ const rectParent = this.cropArea.parentElement.getBoundingClientRect();
826
+ this.cropArea.style.left = xPx - rectParent.left + 'px';
827
+ this.cropArea.style.top = yPx - rectParent.top + 'px';
828
+ }
829
+ async loadVideo(options = {}) {
830
+ this.videoReady = false;
831
+ while (this.video.duration === Infinity || isNaN(this.video.duration) || !this.timebar) {
832
+ await new Promise((r) => setTimeout(r, 1000));
833
+ this.video.currentTime = 10000000 * Math.random();
834
+ }
835
+ this.video.currentTime = 0.01; // BUG: some videos will not play unless this line is present
836
+ // Duration can change if the video is dynamic (stream). This function is to ensure to load all buffer data
837
+ const forceLoadChunks = () => {
838
+ const state = this.videoReady;
839
+ if (this.video.readyState > 3) {
840
+ this.videoReady = true;
841
+ }
842
+ if (!state) {
843
+ this.video.currentTime = this.video.duration;
844
+ }
845
+ };
846
+ this.video.addEventListener('canplaythrough', forceLoadChunks, { passive: true });
847
+ this.video.ondurationchange = (v) => {
848
+ if (this.video.duration != this.endTime) {
849
+ this.video.currentTime = this.startTime;
850
+ console.log('duration changed from', this.endTime, ' to ', this.video.duration);
851
+ this.endTime = this.video.duration;
852
+ this.timebar.setDuration(this.endTime);
853
+ this.timebar.setEndTime(this.endTime);
854
+ }
855
+ this.video.currentTime = this.startTime;
856
+ this.timebar.setCurrentTime(this.video.currentTime);
857
+ };
858
+ this.startTime = 0;
859
+ this.endTime = this.video.duration;
860
+ this.timebar.setDuration(this.endTime);
861
+ this.timebar.setEndTime(this.video.duration);
862
+ this.timebar.setStartTime(this.startTime);
863
+ this.timebar.setCurrentTime(this.startTime);
864
+ // only have one update on flight
865
+ if (!this.requestId) {
866
+ this._update();
867
+ }
868
+ this.controls = options.controls ?? true;
869
+ if (!this.controls) {
870
+ this.hideControls();
871
+ }
872
+ this.cropArea.style.height = this.video.clientHeight + 'px';
873
+ this.cropArea.style.width = this.video.clientWidth + 'px';
874
+ this.moveCropArea(0, 0, true);
875
+ this.resizeCropArea(1, 1, true);
876
+ if (this.crop) {
877
+ this.showCropArea();
878
+ }
879
+ else {
880
+ this.hideCropArea();
881
+ }
882
+ window.addEventListener('keyup', this.onKeyUp);
883
+ if (this.onVideoLoaded) {
884
+ this.onVideoLoaded(this.video);
885
+ }
886
+ }
887
+ _update() {
888
+ // if( this.onDraw ) {
889
+ // this.onDraw();
890
+ // }
891
+ if (this.playing) {
892
+ if (this.video.currentTime + 0.000001 >= this.endTime) {
893
+ this.video.pause();
894
+ if (!this.loop) {
895
+ this.playing = false;
896
+ this.controlsComponents.playBtn?.setState(false, true); // skip callback
897
+ }
898
+ else {
899
+ this.video.currentTime = this.startTime;
900
+ this.video.play();
901
+ }
902
+ }
903
+ this._updateTime = false;
904
+ this.timebar.setCurrentTime(this.video.currentTime);
905
+ this._updateTime = true;
906
+ }
907
+ this.requestId = requestAnimationFrame(this._update.bind(this));
908
+ }
909
+ timeToString(t) {
910
+ let mzminutes = Math.floor(t / 60);
911
+ let mzseconds = Math.floor(t - (mzminutes * 60));
912
+ let mzmiliseconds = Math.floor((t - mzseconds) * 100);
913
+ let mzmilisecondsStr = mzmiliseconds < 10 ? ('0' + mzmiliseconds) : mzmiliseconds.toString();
914
+ let mzsecondsStr = mzseconds < 10 ? ('0' + mzseconds) : mzseconds.toString();
915
+ let mzminutesStr = mzminutes < 10 ? ('0' + mzminutes) : mzminutes.toString();
916
+ return `${mzminutesStr}:${mzsecondsStr}.${mzmilisecondsStr}`;
917
+ }
918
+ _setCurrentTime(t) {
919
+ if (this.video.currentTime != t && this._updateTime) {
920
+ this.video.currentTime = t;
921
+ }
922
+ this.controlsComponents.curTimeText?.set(this.timeToString(t));
923
+ if (this.onSetTime) {
924
+ this.onSetTime(t);
925
+ }
926
+ if (this.onChangeCurrent) {
927
+ this.onChangeCurrent(t);
928
+ }
929
+ }
930
+ _setStartTime(t) {
931
+ this.startTime = this.video.currentTime = t;
932
+ this.controlsComponents.trimStartText?.set(this.timeToString(t));
933
+ if (this.onSetTime) {
934
+ this.onSetTime(t);
935
+ }
936
+ if (this.onChangeStart) {
937
+ this.onChangeStart(t);
938
+ }
939
+ }
940
+ _setEndTime(t) {
941
+ this.endTime = this.video.currentTime = t;
942
+ this.controlsComponents.trimEndText?.set(this.timeToString(t));
943
+ if (this.onSetTime) {
944
+ this.onSetTime(t);
945
+ }
946
+ if (this.onChangeEnd) {
947
+ this.onChangeEnd(t);
948
+ }
949
+ }
950
+ getStartTime() {
951
+ return this.startTime;
952
+ }
953
+ getEndTime() {
954
+ return this.endTime;
955
+ }
956
+ getTrimedTimes() {
957
+ return { start: this.startTime, end: this.endTime };
958
+ }
959
+ getCroppedArea() {
960
+ return this.cropArea.getBoundingClientRect();
961
+ }
962
+ showCropArea() {
963
+ this.crop = true;
964
+ this.cropArea.classList.remove('hidden');
965
+ this.controlsComponents.resetCropBtn?.root.classList.remove('hidden');
966
+ const nodes = this.cropArea.parentElement?.childNodes ?? [];
967
+ const rect = this.cropArea.getBoundingClientRect();
968
+ for (let i = 0; i < nodes.length; i++) {
969
+ const node = nodes[i];
970
+ if (node == this.cropArea)
971
+ continue;
972
+ const rectEl = node.getBoundingClientRect();
973
+ node.style.webkitMask = `linear-gradient(#000 0 0) ${rect.left - rectEl.left}px ${rect.top - rectEl.top}px / ${rect.width}px ${rect.height}px, linear-gradient(rgba(0, 0, 0, 0.3) 0 0)`;
974
+ node.style.webkitMaskRepeat = 'no-repeat';
975
+ }
976
+ }
977
+ hideCropArea() {
978
+ this.crop = false;
979
+ this.cropArea.classList.add('hidden');
980
+ this.controlsComponents.resetCropBtn?.root.classList.add('hidden');
981
+ const nodes = this.cropArea.parentElement?.childNodes ?? [];
982
+ for (let i = 0; i < nodes.length; i++) {
983
+ const node = nodes[i];
984
+ if (node == this.cropArea)
985
+ continue;
986
+ node.style.webkitMask = '';
987
+ node.style.webkitMaskRepeat = 'no-repeat';
988
+ }
989
+ }
990
+ showControls() {
991
+ this.controls = true;
992
+ this.controlsArea.show();
993
+ }
994
+ hideControls() {
995
+ this.controls = false;
996
+ this.controlsArea.hide();
997
+ }
998
+ stopUpdates() {
999
+ if (this.requestId) {
1000
+ cancelAnimationFrame(this.requestId);
1001
+ this.requestId = null;
1002
+ }
1003
+ }
1004
+ unbind() {
1005
+ this.stopUpdates();
1006
+ this.video.pause();
1007
+ this.playing = false;
1008
+ this.controlsComponents.playBtn?.setState(false, true); // skip callback
1009
+ this.video.src = '';
1010
+ if (this.timebar) {
1011
+ this.timebar.unbind();
1012
+ }
1013
+ window.removeEventListener('keyup', this.onKeyUp);
1014
+ document.removeEventListener('mouseup', this._onCropMouseUp);
1015
+ document.removeEventListener('mousemove', this._onCropMouseMove);
1016
+ }
1017
+ }
1018
+ LX.VideoEditor = VideoEditor;
1019
+
1020
+ export { TimeBar, VideoEditor };
1021
+ //# sourceMappingURL=VideoEditor.js.map