underpost 2.85.7 → 2.89.1

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.
Files changed (38) hide show
  1. package/.github/workflows/release.cd.yml +3 -1
  2. package/README.md +2 -2
  3. package/bin/build.js +8 -10
  4. package/bin/index.js +8 -1
  5. package/cli.md +3 -2
  6. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  7. package/manifests/deployment/dd-test-development/deployment.yaml +50 -50
  8. package/manifests/deployment/dd-test-development/proxy.yaml +4 -4
  9. package/package.json +1 -1
  10. package/scripts/rpmfusion-ffmpeg-setup.sh +55 -0
  11. package/src/api/file/file.service.js +29 -3
  12. package/src/cli/baremetal.js +1 -2
  13. package/src/cli/index.js +1 -0
  14. package/src/cli/repository.js +8 -1
  15. package/src/cli/run.js +104 -36
  16. package/src/client/components/core/AgGrid.js +42 -3
  17. package/src/client/components/core/CommonJs.js +4 -0
  18. package/src/client/components/core/Css.js +95 -48
  19. package/src/client/components/core/CssCore.js +0 -1
  20. package/src/client/components/core/LoadingAnimation.js +2 -2
  21. package/src/client/components/core/Logger.js +2 -9
  22. package/src/client/components/core/Modal.js +22 -14
  23. package/src/client/components/core/ObjectLayerEngine.js +300 -9
  24. package/src/client/components/core/ObjectLayerEngineModal.js +686 -148
  25. package/src/client/components/core/ObjectLayerEngineViewer.js +1061 -0
  26. package/src/client/components/core/Pagination.js +57 -12
  27. package/src/client/components/core/Router.js +37 -1
  28. package/src/client/components/core/Translate.js +4 -0
  29. package/src/client/components/core/Worker.js +8 -1
  30. package/src/client/services/default/default.management.js +86 -16
  31. package/src/db/mariadb/MariaDB.js +2 -2
  32. package/src/index.js +1 -1
  33. package/src/server/client-build.js +57 -2
  34. package/src/server/object-layer.js +44 -0
  35. package/src/server/start.js +12 -0
  36. package/src/ws/IoInterface.js +2 -3
  37. package/AUTHORS.md +0 -21
  38. package/src/server/network.js +0 -72
@@ -0,0 +1,1061 @@
1
+ import { loggerFactory } from './Logger.js';
2
+ import { getProxyPath, listenQueryPathInstance } from './Router.js';
3
+ import { ObjectLayerService } from '../../services/object-layer/object-layer.service.js';
4
+ import { NotificationManager } from './NotificationManager.js';
5
+ import { htmls, s } from './VanillaJs.js';
6
+ import { BtnIcon } from './BtnIcon.js';
7
+ import { darkTheme, ThemeEvents } from './Css.js';
8
+ import { ObjectLayerCyberiaPortal } from '../cyberia-portal/ObjectLayerCyberiaPortal.js';
9
+
10
+ const logger = loggerFactory(import.meta);
11
+
12
+ const ObjectLayerEngineViewer = {
13
+ Data: {
14
+ objectLayer: null,
15
+ frameCounts: null,
16
+ currentDirection: 'down',
17
+ currentMode: 'idle',
18
+ gif: null,
19
+ gifWorkerBlob: null,
20
+ isGenerating: false,
21
+ // Binary transparency settings for GIF export
22
+ gifTransparencyPlaceholder: { r: 100, g: 100, b: 100 }, // magenta - unlikely to exist in sprites
23
+ transparencyThreshold: 16, // alpha threshold (0-255) for binary transparency
24
+ },
25
+
26
+ // Map user-friendly direction/mode to numeric direction codes
27
+ getDirectionCode: function (direction, mode) {
28
+ const key = `${direction}_${mode}`;
29
+ const directionCodeMap = {
30
+ down_idle: '08',
31
+ down_walking: '18',
32
+ up_idle: '02',
33
+ up_walking: '12',
34
+ left_idle: '04',
35
+ left_walking: '14',
36
+ right_idle: '06',
37
+ right_walking: '16',
38
+ };
39
+ return directionCodeMap[key] || null;
40
+ },
41
+
42
+ // Get all possible direction names for a direction code
43
+ getDirectionsFromDirectionCode: function (directionCode) {
44
+ const directionMap = {
45
+ '08': ['down_idle', 'none_idle', 'default_idle'],
46
+ 18: ['down_walking'],
47
+ '02': ['up_idle'],
48
+ 12: ['up_walking'],
49
+ '04': ['left_idle', 'up_left_idle', 'down_left_idle'],
50
+ 14: ['left_walking', 'up_left_walking', 'down_left_walking'],
51
+ '06': ['right_idle', 'up_right_idle', 'down_right_idle'],
52
+ 16: ['right_walking', 'up_right_walking', 'down_right_walking'],
53
+ };
54
+ return directionMap[directionCode] || [];
55
+ },
56
+
57
+ Render: async function ({ Elements }) {
58
+ const id = 'object-layer-engine-viewer';
59
+
60
+ // Listen for cid query parameter
61
+ listenQueryPathInstance(
62
+ {
63
+ id: `${id}-query-listener`,
64
+ routeId: 'object-layer-engine-viewer',
65
+ event: async (cid) => {
66
+ if (cid) {
67
+ await this.loadObjectLayer(cid);
68
+ } else {
69
+ this.renderEmpty();
70
+ }
71
+ },
72
+ },
73
+ 'cid',
74
+ );
75
+
76
+ setTimeout(async () => {
77
+ htmls(
78
+ `#${id}`,
79
+ html` <div class="inl section-mp">
80
+ <div class="in">
81
+ <div class="fl">
82
+ <div class="in fll">
83
+ ${await BtnIcon.Render({
84
+ class: 'section-mp main-button',
85
+ label: html`<i class="fa-solid fa-arrow-left"></i> ${' Back'}`,
86
+ attrs: `data-id="btn-back"`,
87
+ })}
88
+ </div>
89
+ </div>
90
+ </div>
91
+ </div>`,
92
+ );
93
+ });
94
+
95
+ return html`
96
+ <div class="fl">
97
+ <div class="in ${id}" id="${id}">
98
+ <div class="in section-mp">
99
+ <div class="in">Loading object layer...</div>
100
+ </div>
101
+ </div>
102
+ </div>
103
+ `;
104
+ },
105
+
106
+ renderEmpty: async function () {
107
+ const id = 'object-layer-engine-viewer';
108
+ htmls(`#${id}`, await ObjectLayerCyberiaPortal.Render());
109
+ },
110
+
111
+ loadObjectLayer: async function (objectLayerId) {
112
+ const id = 'object-layer-engine-viewer';
113
+
114
+ try {
115
+ // Load metadata first
116
+ const { status: metaStatus, data: metadata } = await ObjectLayerService.getMetadata({ id: objectLayerId });
117
+
118
+ if (metaStatus !== 'success' || !metadata) {
119
+ throw new Error('Failed to load object layer metadata');
120
+ }
121
+
122
+ this.Data.objectLayer = metadata;
123
+
124
+ // Load frame counts for all directions
125
+ const { status: frameStatus, data: frameData } = await ObjectLayerService.getFrameCounts({ id: objectLayerId });
126
+
127
+ if (frameStatus !== 'success' || !frameData) {
128
+ throw new Error('Failed to load frame counts');
129
+ }
130
+
131
+ this.Data.frameCounts = frameData.frameCounts;
132
+
133
+ // Auto-select first available direction/mode combination
134
+ this.selectFirstAvailableDirectionMode();
135
+
136
+ // Render the viewer UI
137
+ await this.renderViewer();
138
+
139
+ // Initialize gif.js worker
140
+ await this.initGifJs();
141
+
142
+ // Generate initial GIF
143
+ await this.generateGif();
144
+ } catch (error) {
145
+ logger.error('Error loading object layer:', error);
146
+ NotificationManager.Push({
147
+ html: `Failed to load object layer: ${error.message}`,
148
+ status: 'error',
149
+ });
150
+
151
+ htmls(
152
+ `#${id}`,
153
+ html`
154
+ <div class="in section-mp">
155
+ <div class="in">
156
+ <h3>Error</h3>
157
+ <p>Failed to load object layer. Please try again.</p>
158
+ </div>
159
+ </div>
160
+ `,
161
+ );
162
+ }
163
+ },
164
+
165
+ renderViewer: async function () {
166
+ const id = 'object-layer-engine-viewer';
167
+ const { objectLayer, frameCounts } = this.Data;
168
+
169
+ if (!objectLayer || !frameCounts) return;
170
+
171
+ const itemType = objectLayer.data.item.type;
172
+ const itemId = objectLayer.data.item.id;
173
+ const itemDescription = objectLayer.data.item.description || '';
174
+ const itemActivable = objectLayer.data.item.activable || false;
175
+ const frameDuration = objectLayer.data.render.frame_duration || 100;
176
+ const isStateless = objectLayer.data.render.is_stateless || false;
177
+
178
+ // Get stats data
179
+ const stats = objectLayer.data.stats || {};
180
+
181
+ // Helper function to check if direction/mode has frames
182
+ const hasFrames = (direction, mode) => {
183
+ const numericCode = this.getDirectionCode(direction, mode);
184
+ return numericCode && frameCounts[numericCode] && frameCounts[numericCode] > 0;
185
+ };
186
+
187
+ // Helper function to get frame count
188
+ const getFrameCount = (direction, mode) => {
189
+ const numericCode = this.getDirectionCode(direction, mode);
190
+ return numericCode ? frameCounts[numericCode] || 0 : 0;
191
+ };
192
+ ThemeEvents[id] = () => {
193
+ if (!s(`.style-${id}`)) return;
194
+ htmls(
195
+ `.style-${id}`,
196
+ html` <style>
197
+ .object-layer-viewer-container {
198
+ max-width: 800px;
199
+ margin: 0 auto;
200
+ padding: 20px;
201
+ font-family: 'retro-font';
202
+ }
203
+
204
+ .viewer-header {
205
+ text-align: center;
206
+ margin-bottom: 30px;
207
+ padding-bottom: 20px;
208
+ border-bottom: 2px solid ${darkTheme ? '#444' : '#ddd'};
209
+ }
210
+
211
+ .viewer-header h2 {
212
+ margin: 0 0 10px 0;
213
+ color: ${darkTheme ? '#fff' : '#333'};
214
+ }
215
+
216
+ .gif-display-area {
217
+ background: ${darkTheme ? '#2a2a2a' : '#f5f5f5'};
218
+ border: 2px solid ${darkTheme ? '#444' : '#ddd'};
219
+ border-radius: 12px;
220
+ padding: 30px;
221
+ margin-bottom: 30px;
222
+ display: flex;
223
+ justify-content: center;
224
+ align-items: center;
225
+ min-height: 300px;
226
+ height: auto;
227
+ max-height: 600px;
228
+ position: relative;
229
+ overflow: auto;
230
+ }
231
+
232
+ .gif-canvas-container {
233
+ position: relative;
234
+ display: flex;
235
+ justify-content: center;
236
+ align-items: center;
237
+ width: 100%;
238
+ height: 100%;
239
+ }
240
+
241
+ .gif-canvas-container canvas,
242
+ .gif-canvas-container img {
243
+ image-rendering: pixelated;
244
+ image-rendering: -moz-crisp-edges;
245
+ image-rendering: crisp-edges;
246
+ -ms-interpolation-mode: nearest-neighbor;
247
+ background: repeating-conic-gradient(#80808020 0% 25%, #fff0 0% 50%) 50% / 20px 20px;
248
+ border-radius: 8px;
249
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
250
+ max-width: 100%;
251
+ max-height: 540px;
252
+ width: auto !important;
253
+ height: auto !important;
254
+ object-fit: contain;
255
+ display: block;
256
+ }
257
+
258
+ .gif-canvas-container canvas {
259
+ background: repeating-conic-gradient(#80808020 0% 25%, #fff0 0% 50%) 50% / 20px 20px;
260
+ min-width: 128px;
261
+ min-height: 128px;
262
+ }
263
+
264
+ .gif-info-badge {
265
+ position: absolute;
266
+ bottom: 10px;
267
+ right: 10px;
268
+ background: rgba(0, 0, 0, 0.2);
269
+ color: ${darkTheme ? 'white' : 'black'};
270
+ padding: 6px 12px;
271
+ border-radius: 4px;
272
+ font-size: 12px;
273
+ font-family: monospace;
274
+ backdrop-filter: blur(4px);
275
+ }
276
+
277
+ .gif-info-badge .info-label {
278
+ opacity: 0.7;
279
+ margin-right: 4px;
280
+ }
281
+
282
+ .loading-overlay {
283
+ position: absolute;
284
+ top: 0;
285
+ left: 0;
286
+ right: 0;
287
+ bottom: 0;
288
+ background: rgba(0, 0, 0, 0.7);
289
+ display: flex;
290
+ justify-content: center;
291
+ align-items: center;
292
+ color: white;
293
+ border-radius: 8px;
294
+ z-index: 10;
295
+ }
296
+
297
+ .controls-container {
298
+ display: flex;
299
+ flex-direction: column;
300
+ gap: 20px;
301
+ margin-bottom: 20px;
302
+ }
303
+
304
+ .control-group {
305
+ background: ${darkTheme ? '#2a2a2a' : '#fff'};
306
+ border: 1px solid ${darkTheme ? '#444' : '#ddd'};
307
+ border-radius: 8px;
308
+ padding: 15px 20px;
309
+ }
310
+
311
+ .control-group h4 {
312
+ margin: 0 0 15px 0;
313
+ color: ${darkTheme ? '#fff' : '#333'};
314
+ font-size: 20px;
315
+ text-transform: uppercase;
316
+ letter-spacing: 1px;
317
+ }
318
+
319
+ .button-group {
320
+ display: flex;
321
+ gap: 10px;
322
+ flex-wrap: wrap;
323
+ }
324
+
325
+ .control-btn {
326
+ flex: 1;
327
+ min-width: 80px;
328
+ padding: 12px 20px;
329
+ border: 2px solid ${darkTheme ? '#444' : '#ddd'};
330
+ background: ${darkTheme ? '#333' : '#f9f9f9'};
331
+ color: ${darkTheme ? '#fff' : '#333'};
332
+ border-radius: 6px;
333
+ cursor: pointer;
334
+ transition: all 0.2s ease;
335
+ font-size: 14px;
336
+ font-weight: 600;
337
+ display: flex;
338
+ align-items: center;
339
+ justify-content: center;
340
+ gap: 8px;
341
+ }
342
+
343
+ .control-btn:hover {
344
+ background: ${darkTheme ? '#444' : '#f0f0f0'};
345
+ transform: translateY(-2px);
346
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
347
+ }
348
+
349
+ .control-btn.active {
350
+ background: ${darkTheme ? '#4a9eff' : '#2196F3'};
351
+ color: white;
352
+ border-color: ${darkTheme ? '#4a9eff' : '#2196F3'};
353
+ }
354
+
355
+ .control-btn i {
356
+ font-size: 16px;
357
+ }
358
+
359
+ .download-btn {
360
+ width: 100%;
361
+ padding: 15px;
362
+ background: ${darkTheme ? '#4caf50' : '#4CAF50'};
363
+ color: white;
364
+ border: none;
365
+ border-radius: 8px;
366
+ cursor: pointer;
367
+ font-size: 16px;
368
+ font-weight: 600;
369
+ transition: all 0.2s ease;
370
+ display: flex;
371
+ align-items: center;
372
+ justify-content: center;
373
+ gap: 10px;
374
+ }
375
+
376
+ .download-btn:hover {
377
+ background: ${darkTheme ? '#45a049' : '#45a049'};
378
+ transform: translateY(-2px);
379
+ box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3);
380
+ }
381
+
382
+ .control-btn:disabled {
383
+ background: ${darkTheme ? '#555' : '#ccc'};
384
+ cursor: not-allowed;
385
+ transform: none;
386
+ opacity: 0.5;
387
+ }
388
+
389
+ .control-btn .frame-count {
390
+ font-size: 11px;
391
+ opacity: 0.7;
392
+ margin-left: 4px;
393
+ }
394
+
395
+ .download-btn:disabled {
396
+ background: ${darkTheme ? '#555' : '#ccc'};
397
+ cursor: not-allowed;
398
+ transform: none;
399
+ }
400
+
401
+ @media (max-width: 768px) {
402
+ .gif-display-area {
403
+ max-height: 500px;
404
+ min-height: 300px;
405
+ padding: 20px;
406
+ }
407
+
408
+ .gif-canvas-container canvas,
409
+ .gif-canvas-container img {
410
+ max-width: 100%;
411
+ max-height: 440px;
412
+ }
413
+ }
414
+
415
+ @media (max-width: 600px) {
416
+ .gif-display-area {
417
+ max-height: 400px;
418
+ min-height: 250px;
419
+ padding: 15px;
420
+ }
421
+
422
+ .gif-canvas-container canvas,
423
+ .gif-canvas-container img {
424
+ max-height: 340px;
425
+ }
426
+
427
+ .button-group {
428
+ flex-direction: column;
429
+ }
430
+
431
+ .control-btn {
432
+ min-width: 100%;
433
+ }
434
+ }
435
+ .item-data-key-label {
436
+ font-size: 16px;
437
+ color: ${darkTheme ? '#aaa' : '#666'};
438
+ text-transform: uppercase;
439
+ }
440
+ .item-data-value-label {
441
+ font-size: 20px;
442
+ font-weight: 700;
443
+ color: ${darkTheme ? '#4a9eff' : '#2196F3'};
444
+ }
445
+ .item-stat-entry {
446
+ display: flex;
447
+ flex-direction: column;
448
+ gap: 6px;
449
+ padding: 12px;
450
+ background: ${darkTheme ? '#1a1a1a' : '#f9f9f9'};
451
+ border-radius: 6px;
452
+ border: 1px solid ${darkTheme ? '#333' : '#e0e0e0'};
453
+ }
454
+ .no-data-container {
455
+ grid-column: 1 / -1;
456
+ text-align: center;
457
+ color: ${darkTheme ? '#666' : '#999'};
458
+ padding: 20px;
459
+ }
460
+ </style>`,
461
+ );
462
+ };
463
+ htmls(
464
+ `#${id}`,
465
+ html`
466
+ <div class="hide style-${id}"></div>
467
+
468
+ <div class="object-layer-viewer-container">
469
+ <!-- Item Data Section -->
470
+ <div class="control-group" style="margin-bottom: 20px;">
471
+ <h4><i class="fa-solid fa-cube"></i> Item Data</h4>
472
+ <div
473
+ style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; padding: 10px 0;"
474
+ >
475
+ <div style="display: flex; flex-direction: column; gap: 4px;">
476
+ <span class="item-data-key-label">Item ID</span>
477
+ <span style="font-weight: 600;">${itemId}</span>
478
+ </div>
479
+ <div style="display: flex; flex-direction: column; gap: 4px;">
480
+ <span class="item-data-key-label">Type</span>
481
+ <span style="font-weight: 600;">${itemType}</span>
482
+ </div>
483
+ ${itemDescription
484
+ ? html`<div style="display: flex; flex-direction: column; gap: 4px;">
485
+ <span class="item-data-key-label">Description</span>
486
+ <span style="font-weight: 600;">${itemDescription}</span>
487
+ </div>`
488
+ : ''}
489
+ <div style="display: flex; flex-direction: column; gap: 4px;">
490
+ <span class="item-data-key-label">Activable</span>
491
+ <span style="font-weight: 600;">${itemActivable ? 'Yes' : 'No'}</span>
492
+ </div>
493
+ </div>
494
+ </div>
495
+
496
+ <div class="gif-display-area">
497
+ <div class="gif-canvas-container" id="gif-canvas-container">
498
+ <div style="text-align: center; color: ${darkTheme ? '#aaa' : '#666'};">
499
+ <i class="fa-solid fa-image" style="font-size: 48px; opacity: 0.3; margin-bottom: 16px;"></i>
500
+ <p style="margin: 0; font-size: 14px;">GIF preview will appear here</p>
501
+ </div>
502
+ <div id="gif-loading-overlay" class="loading-overlay" style="display: none;">
503
+ <div>
504
+ <i class="fa-solid fa-spinner fa-spin"></i>
505
+ <span style="margin-left: 10px;">Generating GIF...</span>
506
+ </div>
507
+ </div>
508
+ </div>
509
+ </div>
510
+
511
+ <div class="controls-container">
512
+ <div class="control-group">
513
+ <h4><i class="fa-solid fa-compass"></i> Direction</h4>
514
+ <div class="button-group">
515
+ <button
516
+ class="control-btn ${this.Data.currentDirection === 'up' ? 'active' : ''}"
517
+ data-direction="up"
518
+ ${!hasFrames('up', this.Data.currentMode) ? 'disabled' : ''}
519
+ >
520
+ <i class="fa-solid fa-arrow-up"></i>
521
+ <span>Up</span>
522
+ ${hasFrames('up', this.Data.currentMode)
523
+ ? html`<span class="frame-count">(${getFrameCount('up', this.Data.currentMode)})</span>`
524
+ : ''}
525
+ </button>
526
+ <button
527
+ class="control-btn ${this.Data.currentDirection === 'down' ? 'active' : ''}"
528
+ data-direction="down"
529
+ ${!hasFrames('down', this.Data.currentMode) ? 'disabled' : ''}
530
+ >
531
+ <i class="fa-solid fa-arrow-down"></i>
532
+ <span>Down</span>
533
+ ${hasFrames('down', this.Data.currentMode)
534
+ ? html`<span class="frame-count">(${getFrameCount('down', this.Data.currentMode)})</span>`
535
+ : ''}
536
+ </button>
537
+ <button
538
+ class="control-btn ${this.Data.currentDirection === 'left' ? 'active' : ''}"
539
+ data-direction="left"
540
+ ${!hasFrames('left', this.Data.currentMode) ? 'disabled' : ''}
541
+ >
542
+ <i class="fa-solid fa-arrow-left"></i>
543
+ <span>Left</span>
544
+ ${hasFrames('left', this.Data.currentMode)
545
+ ? html`<span class="frame-count">(${getFrameCount('left', this.Data.currentMode)})</span>`
546
+ : ''}
547
+ </button>
548
+ <button
549
+ class="control-btn ${this.Data.currentDirection === 'right' ? 'active' : ''}"
550
+ data-direction="right"
551
+ ${!hasFrames('right', this.Data.currentMode) ? 'disabled' : ''}
552
+ >
553
+ <i class="fa-solid fa-arrow-right"></i>
554
+ <span>Right</span>
555
+ ${hasFrames('right', this.Data.currentMode)
556
+ ? html`<span class="frame-count">(${getFrameCount('right', this.Data.currentMode)})</span>`
557
+ : ''}
558
+ </button>
559
+ </div>
560
+ </div>
561
+
562
+ <div class="control-group">
563
+ <h4><i class="fa-solid fa-person-running"></i> Mode</h4>
564
+ <div class="button-group">
565
+ <button
566
+ class="control-btn ${this.Data.currentMode === 'idle' ? 'active' : ''}"
567
+ data-mode="idle"
568
+ ${!hasFrames(this.Data.currentDirection, 'idle') ? 'disabled' : ''}
569
+ >
570
+ <i class="fa-solid fa-user"></i>
571
+ <span>Idle</span>
572
+ ${hasFrames(this.Data.currentDirection, 'idle')
573
+ ? html`<span class="frame-count">(${getFrameCount(this.Data.currentDirection, 'idle')})</span>`
574
+ : ''}
575
+ </button>
576
+ <button
577
+ class="control-btn ${this.Data.currentMode === 'walking' ? 'active' : ''}"
578
+ data-mode="walking"
579
+ ${!hasFrames(this.Data.currentDirection, 'walking') ? 'disabled' : ''}
580
+ >
581
+ <i class="fa-solid fa-person-walking"></i>
582
+ <span>Walking</span>
583
+ ${hasFrames(this.Data.currentDirection, 'walking')
584
+ ? html`<span class="frame-count">(${getFrameCount(this.Data.currentDirection, 'walking')})</span>`
585
+ : ''}
586
+ </button>
587
+ </div>
588
+ </div>
589
+ </div>
590
+ <!-- Stats Data Section -->
591
+ <div class="control-group" style="margin-bottom: 20px;">
592
+ <h4><i class="fa-solid fa-chart-bar"></i> Stats Data</h4>
593
+ <div
594
+ style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 15px; padding: 10px 0;"
595
+ >
596
+ ${Object.keys(stats).length > 0
597
+ ? Object.entries(stats)
598
+ .map(
599
+ ([statKey, statValue]) => html`
600
+ <div class="item-stat-entry">
601
+ <span class="item-data-key-label"> ${statKey} </span>
602
+ <span style="item-data-value-label"> ${statValue} </span>
603
+ </div>
604
+ `,
605
+ )
606
+ .join('')
607
+ : html`<div class="no-data-container">No stats data available</div>`}
608
+ </div>
609
+ </div>
610
+ <button class="download-btn" id="download-gif-btn">
611
+ <i class="fa-solid fa-download"></i>
612
+ <span>Download GIF</span>
613
+ </button>
614
+ </div>
615
+ `,
616
+ );
617
+ ThemeEvents[id]();
618
+ // Attach event listeners
619
+ this.attachEventListeners();
620
+ },
621
+
622
+ attachEventListeners: function () {
623
+ // Direction buttons
624
+ const directionButtons = document.querySelectorAll('[data-direction]');
625
+ directionButtons.forEach((btn) => {
626
+ btn.addEventListener('click', async (e) => {
627
+ if (e.currentTarget.disabled) return;
628
+ const direction = e.currentTarget.getAttribute('data-direction');
629
+ if (direction !== this.Data.currentDirection) {
630
+ this.Data.currentDirection = direction;
631
+ await this.renderViewer();
632
+ await this.attachEventListeners();
633
+ await this.generateGif();
634
+ }
635
+ });
636
+ });
637
+
638
+ // Mode buttons
639
+ const modeButtons = document.querySelectorAll('[data-mode]');
640
+ modeButtons.forEach((btn) => {
641
+ btn.addEventListener('click', async (e) => {
642
+ if (e.currentTarget.disabled) return;
643
+ const mode = e.currentTarget.getAttribute('data-mode');
644
+ if (mode !== this.Data.currentMode) {
645
+ this.Data.currentMode = mode;
646
+ await this.renderViewer();
647
+ await this.attachEventListeners();
648
+ await this.generateGif();
649
+ }
650
+ });
651
+ });
652
+
653
+ // Download button
654
+ const downloadBtn = s('#download-gif-btn');
655
+ if (downloadBtn) {
656
+ downloadBtn.addEventListener('click', () => {
657
+ this.downloadGif();
658
+ });
659
+ }
660
+
661
+ // Back button
662
+ setTimeout(() => {
663
+ const backBtn = s('[data-id="btn-back"]');
664
+ if (backBtn) {
665
+ backBtn.addEventListener('click', () => {
666
+ window.history.back();
667
+ });
668
+ }
669
+ }, 100);
670
+ },
671
+
672
+ selectFirstAvailableDirectionMode: function () {
673
+ const { frameCounts } = this.Data;
674
+ if (!frameCounts) return;
675
+
676
+ // Priority order for directions
677
+ const directions = ['down', 'up', 'left', 'right'];
678
+ // Priority order for modes
679
+ const modes = ['idle', 'walking'];
680
+
681
+ // Try to find first available combination using numeric codes
682
+ for (const mode of modes) {
683
+ for (const direction of directions) {
684
+ const numericCode = this.getDirectionCode(direction, mode);
685
+ if (numericCode && frameCounts[numericCode] && frameCounts[numericCode] > 0) {
686
+ this.Data.currentDirection = direction;
687
+ this.Data.currentMode = mode;
688
+ logger.info(`Auto-selected: ${direction} ${mode} (code: ${numericCode}, ${frameCounts[numericCode]} frames)`);
689
+ return;
690
+ }
691
+ }
692
+ }
693
+
694
+ // If no frames found, log warning
695
+ logger.warn('No frames found for any direction/mode combination');
696
+ },
697
+
698
+ initGifJs: async function () {
699
+ if (this.Data.gifWorkerBlob) return; // Already initialized
700
+
701
+ try {
702
+ // Load gif.js library
703
+ await this.loadScript('https://cdn.jsdelivr.net/npm/gif.js@0.2.0/dist/gif.min.js');
704
+
705
+ // Fetch worker script
706
+ const response = await fetch('https://cdn.jsdelivr.net/npm/gif.js@0.2.0/dist/gif.worker.js');
707
+ if (!response.ok) {
708
+ throw new Error('Failed to fetch gif.worker.js');
709
+ }
710
+ const workerBlob = await response.blob();
711
+ this.Data.gifWorkerBlob = URL.createObjectURL(workerBlob);
712
+
713
+ logger.info('gif.js initialized successfully');
714
+ } catch (error) {
715
+ logger.error('Error initializing gif.js:', error);
716
+ throw error;
717
+ }
718
+ },
719
+
720
+ loadScript: function (src) {
721
+ return new Promise((resolve, reject) => {
722
+ // Check if already loaded
723
+ if (document.querySelector(`script[src="${src}"]`)) {
724
+ resolve();
725
+ return;
726
+ }
727
+
728
+ const script = document.createElement('script');
729
+ script.src = src;
730
+ script.onload = resolve;
731
+ script.onerror = reject;
732
+ document.head.appendChild(script);
733
+ });
734
+ },
735
+
736
+ generateGif: async function () {
737
+ if (this.Data.isGenerating) return;
738
+
739
+ const { objectLayer, frameCounts, currentDirection, currentMode } = this.Data;
740
+ if (!objectLayer || !frameCounts) return;
741
+
742
+ // Get numeric direction code
743
+ const numericCode = this.getDirectionCode(currentDirection, currentMode);
744
+ if (!numericCode) {
745
+ NotificationManager.Push({
746
+ html: `Invalid direction/mode combination: ${currentDirection} ${currentMode}`,
747
+ status: 'error',
748
+ });
749
+ return;
750
+ }
751
+
752
+ const frameCount = frameCounts[numericCode];
753
+
754
+ if (!frameCount || frameCount === 0) {
755
+ NotificationManager.Push({
756
+ html: `No frames available for ${currentDirection} ${currentMode}`,
757
+ status: 'warning',
758
+ });
759
+ return;
760
+ }
761
+
762
+ const itemType = objectLayer.data.item.type;
763
+ const itemId = objectLayer.data.item.id;
764
+ const frameDuration = objectLayer.data.render.frame_duration || 100;
765
+
766
+ this.Data.isGenerating = true;
767
+ this.showLoading(true);
768
+
769
+ try {
770
+ // Build frame paths based on frame count using numeric code
771
+ const frames = [];
772
+ for (let i = 0; i < frameCount; i++) {
773
+ frames.push(`${getProxyPath()}assets/${itemType}/${itemId}/${numericCode}/${i}.png`);
774
+ }
775
+
776
+ // Update loading message
777
+ const loadingOverlay = s('#gif-loading-overlay');
778
+ if (loadingOverlay) {
779
+ loadingOverlay.querySelector('span').textContent = `Loading frames... (0/${frames.length})`;
780
+ }
781
+
782
+ // Load all frames to find maximum dimensions
783
+ const loadedImages = [];
784
+ let maxWidth = 0;
785
+ let maxHeight = 0;
786
+
787
+ for (let i = 0; i < frames.length; i++) {
788
+ const img = await this.loadImage(frames[i]);
789
+ loadedImages.push(img);
790
+ maxWidth = Math.max(maxWidth, img.naturalWidth);
791
+ maxHeight = Math.max(maxHeight, img.naturalHeight);
792
+
793
+ // Update progress
794
+ if (loadingOverlay && (i === 0 || i % 5 === 0)) {
795
+ loadingOverlay.querySelector('span').textContent = `Loading frames... (${i + 1}/${frames.length})`;
796
+ }
797
+ }
798
+
799
+ // Update loading message for GIF generation
800
+ if (loadingOverlay) {
801
+ loadingOverlay.querySelector('span').textContent = 'Generating GIF...';
802
+ }
803
+
804
+ logger.info(`GIF dimensions calculated: ${maxWidth}x${maxHeight} from ${frames.length} frames`);
805
+
806
+ // Use binary transparency with placeholder color (magenta)
807
+ const placeholder = this.Data.gifTransparencyPlaceholder;
808
+ const transparentColorHex = (placeholder.r << 16) | (placeholder.g << 8) | placeholder.b;
809
+
810
+ // Create new GIF instance with binary transparency
811
+ const gif = new GIF({
812
+ workers: 2,
813
+ workerScript: this.Data.gifWorkerBlob,
814
+ quality: 10,
815
+ width: maxWidth,
816
+ height: maxHeight,
817
+ transparent: transparentColorHex, // Use magenta as transparent color
818
+ repeat: 0,
819
+ });
820
+
821
+ // Process each frame with binary transparency threshold
822
+ for (let i = 0; i < loadedImages.length; i++) {
823
+ const img = loadedImages[i];
824
+
825
+ // Create canvas for this frame
826
+ const canvas = document.createElement('canvas');
827
+ canvas.width = maxWidth;
828
+ canvas.height = maxHeight;
829
+ const ctx = canvas.getContext('2d', { alpha: true, willReadFrequently: true });
830
+
831
+ // Start with transparent canvas (don't fill with magenta yet)
832
+ ctx.clearRect(0, 0, maxWidth, maxHeight);
833
+
834
+ // Center the image
835
+ const x = Math.floor((maxWidth - img.naturalWidth) / 2);
836
+ const y = Math.floor((maxHeight - img.naturalHeight) / 2);
837
+
838
+ // Disable smoothing to keep pixel-art sharp
839
+ ctx.imageSmoothingEnabled = false;
840
+
841
+ // Draw the original image centered on transparent canvas
842
+ ctx.drawImage(img, x, y);
843
+
844
+ // Apply binary transparency threshold: replace ONLY transparent pixels with placeholder color
845
+ const threshold = this.Data.transparencyThreshold;
846
+ try {
847
+ const imageData = ctx.getImageData(0, 0, maxWidth, maxHeight);
848
+ const data = imageData.data;
849
+
850
+ for (let p = 0; p < data.length; p += 4) {
851
+ const alpha = data[p + 3];
852
+ // If alpha is below threshold, replace with opaque placeholder color (for GIF transparency)
853
+ if (alpha < threshold) {
854
+ data[p] = placeholder.r; // R
855
+ data[p + 1] = placeholder.g; // G
856
+ data[p + 2] = placeholder.b; // B
857
+ data[p + 3] = 255; // A (fully opaque)
858
+ }
859
+ }
860
+
861
+ ctx.putImageData(imageData, 0, 0);
862
+ } catch (err) {
863
+ logger.warn(
864
+ 'Could not access image data for transparency threshold (CORS issue). Transparency may not work correctly.',
865
+ err,
866
+ );
867
+ }
868
+
869
+ // Add frame to GIF with dispose mode to clear between frames
870
+ gif.addFrame(canvas, {
871
+ delay: frameDuration,
872
+ copy: true,
873
+ dispose: 2, // Restore to background color before drawing next frame (prevents overlap)
874
+ });
875
+ }
876
+
877
+ // Handle GIF finished event
878
+ gif.on('finished', (blob) => {
879
+ this.displayGif(blob, maxWidth, maxHeight, frameDuration, frameCount);
880
+ this.Data.gif = blob;
881
+ this.Data.isGenerating = false;
882
+ this.showLoading(false);
883
+ });
884
+
885
+ // Render the GIF
886
+ gif.render();
887
+ } catch (error) {
888
+ logger.error('Error generating GIF:', error);
889
+ NotificationManager.Push({
890
+ html: `Failed to generate GIF: ${error.message}`,
891
+ status: 'error',
892
+ });
893
+ this.Data.isGenerating = false;
894
+ this.showLoading(false);
895
+ }
896
+ },
897
+
898
+ loadImage: function (src) {
899
+ return new Promise((resolve, reject) => {
900
+ const img = new Image();
901
+ img.crossOrigin = 'anonymous';
902
+ img.onload = () => resolve(img);
903
+ img.onerror = reject;
904
+ img.src = src;
905
+ });
906
+ },
907
+
908
+ displayGif: function (blob, originalWidth, originalHeight, frameDuration, frameCount) {
909
+ const container = s('#gif-canvas-container');
910
+ if (!container) return;
911
+
912
+ const url = URL.createObjectURL(blob);
913
+
914
+ // Create img element for the animated GIF
915
+ const gifImg = document.createElement('img');
916
+ gifImg.src = url;
917
+
918
+ gifImg.onload = () => {
919
+ // Use provided dimensions or get from image
920
+ const naturalWidth = originalWidth || gifImg.naturalWidth;
921
+ const naturalHeight = originalHeight || gifImg.naturalHeight;
922
+
923
+ // Calculate intelligent scaling based on container and image size
924
+ const containerEl = s('.gif-display-area');
925
+ const containerWidth = containerEl ? containerEl.clientWidth - 60 : 400; // subtract padding
926
+ const containerHeight = containerEl ? containerEl.clientHeight - 60 : 400;
927
+
928
+ // Calculate scale to fit container while maintaining aspect ratio
929
+ const scaleToFitWidth = containerWidth / naturalWidth;
930
+ const scaleToFitHeight = containerHeight / naturalHeight;
931
+ const scaleToFit = Math.min(scaleToFitWidth, scaleToFitHeight);
932
+
933
+ // For pixel art, use integer scaling for better visuals
934
+ // Minimum 2x for small sprites, but respect container size
935
+ let scale = Math.max(1, Math.floor(scaleToFit));
936
+
937
+ // For very small sprites (< 100px), try to scale up more
938
+ if (Math.max(naturalWidth, naturalHeight) < 100) {
939
+ scale = Math.min(4, Math.floor(scaleToFit));
940
+ }
941
+
942
+ // Make sure scaled image fits in container
943
+ const displayWidth = naturalWidth * scale;
944
+ const displayHeight = naturalHeight * scale;
945
+
946
+ if (displayWidth > containerWidth || displayHeight > containerHeight) {
947
+ scale = Math.max(1, scale - 1);
948
+ }
949
+
950
+ gifImg.style.width = `${naturalWidth * scale}px !important`;
951
+ gifImg.style.height = `${naturalHeight * scale}px !important`;
952
+ gifImg.style.maxWidth = '100%';
953
+ gifImg.style.maxHeight = '540px';
954
+
955
+ // Force pixel-perfect rendering (no antialiasing/blur)
956
+ // gifImg.style.imageRendering = 'pixelated';
957
+ // gifImg.style.imageRendering = '-moz-crisp-edges';
958
+ // gifImg.style.imageRendering = 'crisp-edges';
959
+ // gifImg.style.msInterpolationMode = 'nearest-neighbor';
960
+
961
+ // Prevent any browser scaling optimizations
962
+ // gifImg.style.transform = 'translateZ(0)'; // Force GPU rendering
963
+ // gifImg.style.backfaceVisibility = 'hidden'; // Prevent subpixel rendering
964
+
965
+ // Clear container and add the GIF
966
+ container.innerHTML = '';
967
+ container.appendChild(gifImg);
968
+
969
+ // Re-add loading overlay
970
+ const overlay = document.createElement('div');
971
+ overlay.id = 'gif-loading-overlay';
972
+ overlay.className = 'loading-overlay';
973
+ overlay.style.display = 'none';
974
+ overlay.innerHTML = html`
975
+ <div>
976
+ <i class="fa-solid fa-spinner fa-spin"></i>
977
+ <span style="margin-left: 10px;">Generating GIF...</span>
978
+ </div>
979
+ `;
980
+ container.appendChild(overlay);
981
+
982
+ // Add info badge with dimensions and scale
983
+ const infoBadge = document.createElement('div');
984
+ infoBadge.className = 'gif-info-badge';
985
+ const displayW = Math.round(naturalWidth * scale);
986
+ const displayH = Math.round(naturalHeight * scale);
987
+ infoBadge.innerHTML = html`
988
+ <span class="info-label">Dimensions:</span> ${naturalWidth}x${naturalHeight}px<br />
989
+ <span class="info-label">Display:</span> ${displayW}x${displayH}px<br />
990
+ ${scale > 1 ? `<span class="info-label">Scale:</span> ${scale}x<br />` : ''}
991
+ <span class="info-label">Frames:</span> ${frameCount}<br />
992
+ <span class="info-label">Frame Duration:</span> ${frameDuration}ms<br />
993
+ <span class="info-label">Total Duration:</span> ${(frameDuration * frameCount) / 1000}s
994
+ `;
995
+ s(`.gif-display-area`).appendChild(infoBadge);
996
+
997
+ logger.info(`Displaying GIF: ${naturalWidth}x${naturalHeight} at ${scale}x scale (${displayW}x${displayH})`);
998
+ };
999
+
1000
+ gifImg.onerror = () => {
1001
+ logger.error('Failed to load GIF image');
1002
+ NotificationManager.Push({
1003
+ html: 'Failed to display GIF',
1004
+ status: 'error',
1005
+ });
1006
+ };
1007
+ },
1008
+
1009
+ showLoading: function (show) {
1010
+ const overlay = s('#gif-loading-overlay');
1011
+ if (overlay) {
1012
+ overlay.style.display = show ? 'flex' : 'none';
1013
+ }
1014
+
1015
+ const downloadBtn = s('#download-gif-btn');
1016
+ if (downloadBtn) {
1017
+ downloadBtn.disabled = show;
1018
+ }
1019
+ },
1020
+
1021
+ downloadGif: function () {
1022
+ if (!this.Data.gif) {
1023
+ NotificationManager.Push({
1024
+ html: 'No GIF available to download',
1025
+ status: 'warning',
1026
+ });
1027
+ return;
1028
+ }
1029
+
1030
+ const { objectLayer, currentDirection, currentMode } = this.Data;
1031
+ const numericCode = this.getDirectionCode(currentDirection, currentMode);
1032
+ const filename = `${objectLayer.data.item.id}_${currentDirection}_${currentMode}_${numericCode}.gif`;
1033
+
1034
+ const url = URL.createObjectURL(this.Data.gif);
1035
+ const a = document.createElement('a');
1036
+ a.href = url;
1037
+ a.download = filename;
1038
+ document.body.appendChild(a);
1039
+ a.click();
1040
+ document.body.removeChild(a);
1041
+ URL.revokeObjectURL(url);
1042
+
1043
+ NotificationManager.Push({
1044
+ html: `GIF downloaded: ${filename}`,
1045
+ status: 'success',
1046
+ });
1047
+ },
1048
+
1049
+ Reload: async function () {
1050
+ const queryParams = new URLSearchParams(window.location.search);
1051
+ const cid = queryParams.get('cid');
1052
+
1053
+ if (cid) {
1054
+ await this.loadObjectLayer(cid);
1055
+ } else {
1056
+ this.renderEmpty();
1057
+ }
1058
+ },
1059
+ };
1060
+
1061
+ export { ObjectLayerEngineViewer };