@spark-apps/piclet 1.0.4 → 1.0.6

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.
@@ -69,16 +69,24 @@ input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;hei
69
69
  .log-preview{width:80px;height:80px;object-fit:contain;background:var(--bg2);border-radius:5px;border:1px solid var(--brd);flex-shrink:0;display:none;align-self:center}
70
70
  .log-preview.on{display:block}
71
71
 
72
- /* Done modal */
73
- #doneModal{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:100;align-items:center;justify-content:center}
74
- #doneModal.on{display:flex}
75
- .done-modal{background:var(--bg2);border:1px solid var(--brd);border-radius:8px;padding:16px 24px;text-align:center;min-width:200px}
76
- .done-modal h4{font-size:14px;margin:0 0 4px}
77
- .done-modal.ok h4{color:var(--ok)}
78
- .done-modal.err h4{color:var(--err)}
79
- .done-modal p{font-size:11px;color:var(--txt2);margin:0 0 12px;word-break:break-word}
80
- .done-btns{display:flex;gap:8px;justify-content:center}
81
- .done-btns .btn{padding:8px 16px;font-size:12px}
72
+ /* Done container (replaces log area) */
73
+ .done-container{display:none;flex-direction:column;gap:8px;flex:1;min-height:0;overflow:hidden}
74
+ .done-container.on{display:flex}
75
+ .done-preview{display:flex;width:100%;max-height:80%;align-items:center;justify-content:center;background:var(--bg2);border-radius:6px;overflow:hidden;position:relative}
76
+ .done-preview::before{content:'';position:absolute;inset:0;background:repeating-conic-gradient(#1a1a1d 0% 25%, #222 0% 50%) 50%/10px 10px;z-index:0}
77
+ .done-preview img{max-width:100%;height:auto;object-fit:contain;position:relative;z-index:1}
78
+ .done-logs{flex:1 1 auto;font:10px/1.4 Consolas,monospace;background:var(--bg2);border-radius:6px;padding:8px;overflow-y:auto;min-height:60px}
79
+ .done-logs p{margin:2px 0}
80
+ .done-logs .i{color:var(--txt3)}
81
+ .done-logs .s{color:var(--ok)}
82
+ .done-logs .e{color:var(--err)}
83
+ .done-result{display:flex;align-items:center;gap:12px;padding:8px 12px;background:var(--bg2);border-radius:6px;flex:0 0 auto}
84
+ .done-result h4{font-size:13px;margin:0;font-weight:600}
85
+ .done-container.ok .done-result h4{color:var(--ok)}
86
+ .done-container.err .done-result h4{color:var(--err)}
87
+ .done-result p{font-size:11px;color:var(--txt2);margin:0;flex:1}
88
+ .done-btns{display:flex;gap:8px;flex:0 0 auto}
89
+ .done-btns .btn{padding:8px 16px;font-size:11px}
82
90
 
83
91
  /* Warning/info box */
84
92
  .warn-box{display:none;font-size:10px;color:#ca8a04;background:rgba(202,138,4,0.1);padding:6px 8px;border-radius:4px;text-align:center}
@@ -400,13 +400,14 @@
400
400
  <div class="log" id="G"></div>
401
401
  <img class="log-preview" id="LP">
402
402
  </div>
403
- </div>
404
-
405
- <!-- Done modal -->
406
- <div class="modal-overlay" id="doneModal">
407
- <div class="done-modal" id="D">
408
- <h4 id="dT"></h4>
409
- <p id="dM"></p>
403
+ <!-- Done state (replaces log container) -->
404
+ <div class="done-container" id="DC">
405
+ <div class="done-preview" id="donePreview"></div>
406
+ <div class="done-logs" id="doneLogs"></div>
407
+ <div class="done-result">
408
+ <h4 id="dT"></h4>
409
+ <p id="dM"></p>
410
+ </div>
410
411
  <div class="done-btns">
411
412
  <button class="btn btn-g" onclick="reset()">Back</button>
412
413
  <button class="btn" onclick="openOutputFolder()" id="openFolderBtn">Open Folder</button>
@@ -780,18 +781,7 @@ function schedulePreview() {
780
781
 
781
782
  // Show original image
782
783
  async function showOriginal() {
783
- // For GIFs with frames loaded, show selected frame directly
784
- if (isGifFile && gifFrames.length > 0 && gifFrames[selectedFrameIndex]) {
785
- const frame = gifFrames[selectedFrameIndex];
786
- pA.innerHTML = '';
787
- const img = document.createElement('img');
788
- img.src = frame.thumbnail;
789
- pA.appendChild(img);
790
- pI.textContent = `${imageInfo.width} × ${imageInfo.height} (Frame ${selectedFrameIndex + 1} of ${imageInfo.frameCount})`;
791
- updateZoom();
792
- return;
793
- }
794
-
784
+ // Always fetch full-scale preview from API (not the 96px thumbnails)
795
785
  pA.innerHTML = '<div class="mini-sp"></div>';
796
786
  try {
797
787
  const opts = { tools: [], original: true };
@@ -806,7 +796,7 @@ async function showOriginal() {
806
796
  pA.appendChild(img);
807
797
  const frameInfo = isGifFile && imageInfo.frameCount > 1 ? ` (Frame ${selectedFrameIndex + 1} of ${imageInfo.frameCount})` : '';
808
798
  pI.textContent = `${imageInfo.width} × ${imageInfo.height}${frameInfo}`;
809
- updateZoom(); // Apply current zoom
799
+ updateZoom();
810
800
  }
811
801
  } catch (e) {
812
802
  pA.innerHTML = '<span class="placeholder">Failed to load image</span>';
@@ -1122,16 +1112,18 @@ function updatePlaybackFrame() {
1122
1112
  thumb.classList.toggle('selected', i === selectedFrameIndex);
1123
1113
  });
1124
1114
 
1125
- // Update main preview
1126
- const frame = gifFrames[selectedFrameIndex];
1127
- if (frame && frame.thumbnail) {
1115
+ // Get the frame image from the strip (includes applied effects if active)
1116
+ const frameThumb = document.querySelector(`.frame-thumb[data-index="${selectedFrameIndex}"] img`);
1117
+ const frameSrc = frameThumb?.src || (gifFrames[selectedFrameIndex]?.thumbnail);
1118
+
1119
+ if (frameSrc) {
1128
1120
  const img = pA.querySelector('img');
1129
1121
  if (img) {
1130
- img.src = frame.thumbnail;
1122
+ img.src = frameSrc;
1131
1123
  } else {
1132
1124
  pA.innerHTML = '';
1133
1125
  const newImg = document.createElement('img');
1134
- newImg.src = frame.thumbnail;
1126
+ newImg.src = frameSrc;
1135
1127
  pA.appendChild(newImg);
1136
1128
  updateZoom();
1137
1129
  }
@@ -1187,16 +1179,8 @@ function selectFrame(index) {
1187
1179
 
1188
1180
  async function showSelectedFrame() {
1189
1181
  if (activeTools.size === 0) {
1190
- // Show original frame
1191
- const frame = gifFrames[selectedFrameIndex];
1192
- if (frame && frame.thumbnail) {
1193
- pA.innerHTML = '';
1194
- const img = document.createElement('img');
1195
- img.src = frame.thumbnail;
1196
- pA.appendChild(img);
1197
- pI.textContent = `Frame ${selectedFrameIndex + 1} of ${imageInfo.frameCount}`;
1198
- updateZoom();
1199
- }
1182
+ // Fetch full-scale frame from API
1183
+ showOriginal();
1200
1184
  } else {
1201
1185
  // Regenerate preview for selected frame
1202
1186
  generatePreview();
@@ -1285,12 +1269,11 @@ async function runExport(opts, message) {
1285
1269
  if (result.logs) {
1286
1270
  result.logs.forEach(l => log('G', l.type[0], l.message));
1287
1271
  }
1288
- // Try to show output preview
1289
- if (result.success) await showLogPreview();
1290
- showDone(result.success, result.success ? 'Done' : 'Failed', result.success ? result.output : result.error);
1272
+ // Show done state with preview
1273
+ await showDone(result.success, result.success ? 'Done' : 'Failed', result.success ? result.output : result.error);
1291
1274
  } catch (e) {
1292
1275
  log('G', 'e', e.message);
1293
- showDone(false, 'Error', e.message);
1276
+ await showDone(false, 'Error', e.message);
1294
1277
  }
1295
1278
  }
1296
1279
 
@@ -1677,19 +1660,18 @@ async function apply() {
1677
1660
  result.logs.forEach(l => log('G', l.type[0], l.message));
1678
1661
  }
1679
1662
 
1680
- // Try to show output preview
1681
- if (result.success) await showLogPreview();
1682
- showDone(result.success, result.success ? 'Done' : 'Failed', result.success ? result.output : result.error);
1663
+ // Show done state with preview
1664
+ await showDone(result.success, result.success ? 'Done' : 'Failed', result.success ? result.output : result.error);
1683
1665
  } catch (e) {
1684
1666
  log('G', 'e', e.message);
1685
- showDone(false, 'Error', e.message);
1667
+ await showDone(false, 'Error', e.message);
1686
1668
  }
1687
1669
  }
1688
1670
 
1689
1671
  // Reset to main view
1690
1672
  function reset() {
1691
- $('doneModal').classList.remove('on');
1692
- $('D').classList.remove('ok', 'err');
1673
+ $('DC').classList.remove('on', 'ok', 'err');
1674
+ $('donePreview').classList.remove('on');
1693
1675
  $('LC').classList.remove('on');
1694
1676
  $('LP').classList.remove('on');
1695
1677
  $('M').classList.remove('hide');
@@ -1722,23 +1704,64 @@ async function showLogPreview() {
1722
1704
  } catch { /* ignore */ }
1723
1705
  }
1724
1706
 
1725
- // Show done modal
1726
- function showDone(success, title, message) {
1707
+ // Show done state (replaces log container)
1708
+ async function showDone(success, title, message) {
1727
1709
  $('L').classList.remove('on');
1728
- $('doneModal').classList.add('on');
1729
- $('D').classList.add(success ? 'ok' : 'err');
1730
- $('D').classList.remove(success ? 'err' : 'ok');
1710
+ $('LC').classList.remove('on'); // Hide log container
1711
+ $('DC').classList.add('on', success ? 'ok' : 'err');
1712
+ $('DC').classList.remove(success ? 'err' : 'ok');
1731
1713
  $('dT').textContent = title;
1732
1714
  $('dM').textContent = message;
1733
1715
  lastOutputSuccess = success;
1734
1716
  $('openFolderBtn').style.display = success ? '' : 'none';
1735
1717
  if (success) hasChanges = false;
1718
+
1719
+ // Copy logs to done container
1720
+ $('doneLogs').innerHTML = $('G').innerHTML;
1721
+
1722
+ // Fetch and show thumbnail
1723
+ const donePreview = $('donePreview');
1724
+ if (success) {
1725
+ try {
1726
+ const result = await fetchJson('/api/output-preview');
1727
+ if (result.success && result.imageData) {
1728
+ donePreview.innerHTML = '';
1729
+ const img = document.createElement('img');
1730
+ img.src = result.imageData;
1731
+ img.alt = 'Output';
1732
+ donePreview.appendChild(img);
1733
+ } else {
1734
+ // Show error in preview area
1735
+ donePreview.innerHTML = `<span style="color:var(--err);font-size:10px;z-index:1;position:relative">${result.error || 'No preview'}</span>`;
1736
+ }
1737
+ } catch (e) {
1738
+ donePreview.innerHTML = `<span style="color:var(--err);font-size:10px;z-index:1;position:relative">Error: ${e.message}</span>`;
1739
+ }
1740
+ } else {
1741
+ donePreview.innerHTML = '';
1742
+ }
1736
1743
  }
1737
1744
 
1738
1745
  // Init
1739
1746
  setupDragDrop();
1740
1747
  setupDivider();
1741
1748
 
1749
+ // Spacebar to toggle GIF playback
1750
+ document.addEventListener('keydown', (e) => {
1751
+ // Only handle space if not typing in an input
1752
+ if (e.code === 'Space' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) {
1753
+ if (isGifFile && gifFrames.length > 1) {
1754
+ e.preventDefault();
1755
+ const wasPlaying = isPlaying;
1756
+ togglePlayback();
1757
+ // When stopping, refresh to full-scale preview
1758
+ if (wasPlaying) {
1759
+ showSelectedFrame();
1760
+ }
1761
+ }
1762
+ }
1763
+ });
1764
+
1742
1765
  // Resizable dividers
1743
1766
  function setupDivider() {
1744
1767
  const divider = $('divider');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spark-apps/piclet",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Lightweight image tools for content creators",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,229 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en" data-piclet data-width="400" data-height="440">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width,initial-scale=1">
6
- <title>Add Border</title>
7
- <link rel="stylesheet" href="/css/theme.css">
8
- <script src="/js/piclet.js"></script>
9
- <style>
10
- .preview-area{flex:1;display:flex;align-items:center;justify-content:center;background:var(--bg2);border-radius:6px;min-height:140px;overflow:hidden;position:relative}
11
- .preview-area::before{content:'';position:absolute;inset:0;background:repeating-conic-gradient(#1a1a1d 0% 25%, #222 0% 50%) 50%/16px 16px;z-index:0}
12
- .preview-area img{max-width:100%;max-height:100%;object-fit:contain;position:relative;z-index:1}
13
- .preview-area .placeholder{position:relative;z-index:1;font-size:11px;color:var(--txt3)}
14
- .preview-area .mini-sp{width:16px;height:16px;border:2px solid var(--bg3);border-top-color:var(--acc);border-radius:50%;animation:s .5s linear infinite;position:relative;z-index:1}
15
- .preview-info{font-size:10px;color:var(--txt3);text-align:center;margin-top:4px;height:14px}
16
- .controls{display:flex;flex-direction:column;gap:10px}
17
- .control-row{display:flex;align-items:center;gap:10px}
18
- .control-row label{font-size:11px;color:var(--txt3);width:50px;flex-shrink:0}
19
- .control-row input[type="range"]{flex:1;height:4px;background:var(--bg3);border-radius:3px;-webkit-appearance:none}
20
- .control-row input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;background:var(--acc);border-radius:50%;cursor:pointer}
21
- .control-row .val{width:40px;font-size:12px;color:var(--acc2);text-align:right;font-weight:500}
22
- .color-row{display:flex;align-items:center;gap:10px}
23
- .color-row label{font-size:11px;color:var(--txt3);width:50px;flex-shrink:0}
24
- .color-input-wrap{flex:1;display:flex;align-items:center;gap:8px}
25
- .color-preview{width:28px;height:28px;border-radius:4px;border:1px solid var(--brd);cursor:pointer;flex-shrink:0}
26
- .color-input-wrap input[type="color"]{position:absolute;opacity:0;width:28px;height:28px;cursor:pointer}
27
- .color-input-wrap input[type="text"]{flex:1;padding:6px 10px;background:var(--bg2);border:1px solid var(--brd);border-radius:5px;color:var(--txt);font:inherit;font-size:12px}
28
- .color-input-wrap input[type="text"]:focus{outline:none;border-color:var(--acc)}
29
- .preset-colors{display:flex;gap:4px;margin-left:auto}
30
- .preset-color{width:20px;height:20px;border-radius:3px;border:1px solid var(--brd);cursor:pointer;transition:transform .1s}
31
- .preset-color:hover{transform:scale(1.1)}
32
- </style>
33
- </head>
34
- <body>
35
- <div class="app">
36
- <div class="hd"><b>PicLet</b><span>Add Border</span></div>
37
- <div class="meta">
38
- <div>File<b id="fn">-</b></div>
39
- <div>Size<b id="sz">-</b></div>
40
- </div>
41
- <!-- Form -->
42
- <div id="F" class="form">
43
- <div class="preview-area" id="pA">
44
- <span class="placeholder">Adjust to preview</span>
45
- </div>
46
- <div class="preview-info" id="pI"></div>
47
- <div class="controls">
48
- <div class="control-row">
49
- <label>Width</label>
50
- <input type="range" id="wR" min="1" max="100" value="10">
51
- <span class="val" id="wV">10px</span>
52
- </div>
53
- <div class="color-row">
54
- <label>Color</label>
55
- <div class="color-input-wrap">
56
- <div class="color-preview" id="cP" style="background:#ffffff"></div>
57
- <input type="color" id="cC" value="#ffffff">
58
- <input type="text" id="cT" value="#ffffff" placeholder="#ffffff">
59
- <div class="preset-colors">
60
- <div class="preset-color" style="background:#ffffff" data-color="#ffffff"></div>
61
- <div class="preset-color" style="background:#000000" data-color="#000000"></div>
62
- <div class="preset-color" style="background:#eab308" data-color="#eab308"></div>
63
- <div class="preset-color" style="background:#ef4444" data-color="#ef4444"></div>
64
- <div class="preset-color" style="background:#3b82f6" data-color="#3b82f6"></div>
65
- </div>
66
- </div>
67
- </div>
68
- </div>
69
- <div class="btns">
70
- <button class="btn btn-g" onclick="PicLet.close()">Cancel</button>
71
- <button class="btn btn-p" onclick="apply()">Apply</button>
72
- </div>
73
- </div>
74
- <!-- Loading state -->
75
- <div class="ld" id="L"><div class="sp"></div><span id="lT">Adding border...</span></div>
76
- <!-- Log -->
77
- <div class="log" id="G"></div>
78
- <!-- Done state -->
79
- <div class="dn" id="D">
80
- <h4 id="dT"></h4>
81
- <p id="dM"></p>
82
- <div class="btns" style="width:100%;margin-top:8px">
83
- <button class="btn btn-p" onclick="PicLet.close()">Done</button>
84
- </div>
85
- </div>
86
- </div>
87
- <script>
88
- const { $, log, fetchJson, postJson } = PicLet;
89
- const pA = $('pA'), pI = $('pI');
90
- const wR = $('wR'), wV = $('wV');
91
- const cC = $('cC'), cT = $('cT'), cP = $('cP');
92
-
93
- let previewTimeout = null;
94
- let isSliding = false;
95
- let lastPreviewOpts = null;
96
-
97
- // Width slider
98
- wR.oninput = () => { wV.textContent = wR.value + 'px'; };
99
- wR.addEventListener('mousedown', () => { isSliding = true; });
100
- wR.addEventListener('mouseup', () => { isSliding = false; schedulePreview(); });
101
- wR.addEventListener('mouseleave', () => { if (isSliding) { isSliding = false; schedulePreview(); } });
102
-
103
- // Color picker
104
- cC.oninput = () => {
105
- cT.value = cC.value;
106
- cP.style.background = cC.value;
107
- schedulePreview();
108
- };
109
-
110
- cT.oninput = () => {
111
- const val = cT.value;
112
- if (/^#[0-9a-fA-F]{6}$/.test(val)) {
113
- cC.value = val;
114
- cP.style.background = val;
115
- schedulePreview();
116
- }
117
- };
118
-
119
- cT.onblur = () => {
120
- schedulePreview();
121
- };
122
-
123
- cP.onclick = () => cC.click();
124
-
125
- // Preset colors
126
- document.querySelectorAll('.preset-color').forEach(el => {
127
- el.onclick = () => {
128
- const color = el.dataset.color;
129
- cC.value = color;
130
- cT.value = color;
131
- cP.style.background = color;
132
- schedulePreview();
133
- };
134
- });
135
-
136
- // Get current options
137
- function getOptions() {
138
- return {
139
- width: +wR.value,
140
- color: cT.value || '#ffffff'
141
- };
142
- }
143
-
144
- // Check if options changed
145
- function optionsChanged() {
146
- const current = JSON.stringify(getOptions());
147
- if (current === lastPreviewOpts) return false;
148
- lastPreviewOpts = current;
149
- return true;
150
- }
151
-
152
- // Schedule preview
153
- function schedulePreview() {
154
- if (isSliding) return;
155
- if (previewTimeout) clearTimeout(previewTimeout);
156
- previewTimeout = setTimeout(() => {
157
- if (!isSliding && optionsChanged()) {
158
- generatePreview();
159
- }
160
- }, 300);
161
- }
162
-
163
- // Generate preview
164
- async function generatePreview() {
165
- pA.innerHTML = '<div class="mini-sp"></div>';
166
- pI.textContent = '';
167
-
168
- try {
169
- const result = await postJson('/api/preview', getOptions());
170
-
171
- if (result.success && result.imageData) {
172
- const img = document.createElement('img');
173
- img.src = result.imageData;
174
- img.alt = 'Preview';
175
- pA.innerHTML = '';
176
- pA.appendChild(img);
177
- pI.textContent = result.width && result.height ? `${result.width}×${result.height}` : '';
178
- } else {
179
- pA.innerHTML = '<span class="placeholder">' + (result.error || 'Preview failed') + '</span>';
180
- pI.textContent = '';
181
- }
182
- } catch (e) {
183
- pA.innerHTML = '<span class="placeholder">Preview error</span>';
184
- pI.textContent = '';
185
- }
186
- }
187
-
188
- // Load initial data
189
- fetchJson('/api/info').then(d => {
190
- $('fn').textContent = d.fileName;
191
- $('sz').textContent = d.width + '×' + d.height;
192
- if (d.defaults) {
193
- wR.value = d.defaults.width || 10;
194
- wV.textContent = wR.value + 'px';
195
- const color = d.defaults.color || '#ffffff';
196
- cC.value = color;
197
- cT.value = color;
198
- cP.style.background = color;
199
- }
200
- setTimeout(generatePreview, 100);
201
- });
202
-
203
- // Apply
204
- async function apply() {
205
- $('F').classList.add('hide');
206
- $('L').classList.add('on');
207
- $('G').classList.add('on');
208
-
209
- try {
210
- const result = await postJson('/api/process', getOptions());
211
- if (result.logs) {
212
- result.logs.forEach(l => log('G', l.type[0], l.message));
213
- }
214
-
215
- $('L').classList.remove('on');
216
- $('D').classList.add('on', result.success ? 'ok' : 'err');
217
- $('dT').textContent = result.success ? 'Done' : 'Failed';
218
- $('dM').textContent = result.success ? result.output : result.error;
219
- } catch (e) {
220
- log('G', 'e', e.message);
221
- $('L').classList.remove('on');
222
- $('D').classList.add('on', 'err');
223
- $('dT').textContent = 'Error';
224
- $('dM').textContent = e.message;
225
- }
226
- }
227
- </script>
228
- </body>
229
- </html>
@@ -1,156 +0,0 @@
1
- <!DOCTYPE html>
2
- <html data-piclet data-width="400" data-height="420">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width,initial-scale=1">
6
- <title>Extract Frames</title>
7
- <link rel="stylesheet" href="/css/theme.css">
8
- <script src="/js/piclet.js"></script>
9
- <style>
10
- .preview-area{flex:1;display:flex;align-items:center;justify-content:center;background:var(--bg2);border-radius:6px;min-height:160px;overflow:hidden;position:relative}
11
- .preview-area::before{content:'';position:absolute;inset:0;background:repeating-conic-gradient(#1a1a1d 0% 25%, #222 0% 50%) 50%/16px 16px;z-index:0}
12
- .preview-area img{max-width:100%;max-height:100%;object-fit:contain;position:relative;z-index:1}
13
- .preview-area .placeholder{position:relative;z-index:1;font-size:11px;color:var(--txt3)}
14
- .preview-area .mini-sp{width:16px;height:16px;border:2px solid var(--bg3);border-top-color:var(--acc);border-radius:50%;animation:s .5s linear infinite;position:relative;z-index:1}
15
- .preview-info{font-size:10px;color:var(--txt3);text-align:center;margin-top:4px;height:14px}
16
- .frame-nav{display:flex;align-items:center;justify-content:center;gap:12px;margin-top:8px}
17
- .frame-nav button{width:32px;height:32px;border-radius:6px;border:none;background:var(--bg3);color:var(--txt2);font-size:14px;cursor:pointer;display:flex;align-items:center;justify-content:center}
18
- .frame-nav button:hover{background:var(--acc);color:#fff}
19
- .frame-nav button:disabled{opacity:0.3;cursor:not-allowed}
20
- .frame-nav span{font-size:12px;color:var(--txt2);min-width:80px;text-align:center}
21
- .info-box{background:var(--bg2);border-radius:6px;padding:12px;margin-top:8px;text-align:center}
22
- .info-box .count{font-size:24px;font-weight:600;color:var(--acc2)}
23
- .info-box .label{font-size:11px;color:var(--txt3);margin-top:2px}
24
- </style>
25
- </head>
26
- <body>
27
- <div class="app">
28
- <div class="hd"><b>PicLet</b><span>Extract Frames</span></div>
29
- <div class="meta">
30
- <div>File<b id="fn">-</b></div>
31
- <div>Size<b id="sz">-</b></div>
32
- </div>
33
- <!-- Form -->
34
- <div id="F" class="form">
35
- <div class="preview-area" id="pA">
36
- <span class="placeholder">Loading...</span>
37
- </div>
38
- <div class="preview-info" id="pI"></div>
39
- <div class="frame-nav">
40
- <button id="bP" title="Previous frame">&lt;</button>
41
- <span id="fN">Frame 1 / 1</span>
42
- <button id="bN" title="Next frame">&gt;</button>
43
- </div>
44
- <div class="info-box">
45
- <div class="count" id="fC">0</div>
46
- <div class="label">frames will be extracted as PNG files</div>
47
- </div>
48
- <div class="btns">
49
- <button class="btn btn-g" onclick="PicLet.close()">Cancel</button>
50
- <button class="btn btn-p" onclick="apply()">Extract All</button>
51
- </div>
52
- </div>
53
- <!-- Loading state -->
54
- <div class="ld" id="L"><div class="sp"></div><span id="lT">Extracting...</span></div>
55
- <!-- Log -->
56
- <div class="log" id="G"></div>
57
- <!-- Done state -->
58
- <div class="dn" id="D">
59
- <h4 id="dT"></h4>
60
- <p id="dM"></p>
61
- <div class="btns" style="width:100%;margin-top:8px">
62
- <button class="btn btn-p" onclick="PicLet.close()">Done</button>
63
- </div>
64
- </div>
65
- </div>
66
- <script>
67
- const { $, log, fetchJson, postJson } = PicLet;
68
- const pA = $('pA'), pI = $('pI'), fN = $('fN'), fC = $('fC');
69
- const bP = $('bP'), bN = $('bN');
70
-
71
- let frameCount = 1;
72
- let currentFrame = 0;
73
-
74
- // Update frame display
75
- function updateFrameDisplay() {
76
- fN.textContent = `Frame ${currentFrame + 1} / ${frameCount}`;
77
- bP.disabled = currentFrame === 0;
78
- bN.disabled = currentFrame >= frameCount - 1;
79
- }
80
-
81
- // Load frame preview
82
- async function loadFrame(index) {
83
- pA.innerHTML = '<div class="mini-sp"></div>';
84
- pI.textContent = '';
85
-
86
- try {
87
- const result = await postJson('/api/preview', { frameIndex: index });
88
- if (result.success && result.imageData) {
89
- const img = document.createElement('img');
90
- img.src = result.imageData;
91
- img.alt = `Frame ${index + 1}`;
92
- pA.innerHTML = '';
93
- pA.appendChild(img);
94
- pI.textContent = result.width && result.height ? `${result.width}x${result.height}` : '';
95
- } else {
96
- pA.innerHTML = '<span class="placeholder">' + (result.error || 'Preview failed') + '</span>';
97
- }
98
- } catch (e) {
99
- pA.innerHTML = '<span class="placeholder">Preview error</span>';
100
- }
101
- }
102
-
103
- // Navigation
104
- bP.onclick = () => {
105
- if (currentFrame > 0) {
106
- currentFrame--;
107
- updateFrameDisplay();
108
- loadFrame(currentFrame);
109
- }
110
- };
111
-
112
- bN.onclick = () => {
113
- if (currentFrame < frameCount - 1) {
114
- currentFrame++;
115
- updateFrameDisplay();
116
- loadFrame(currentFrame);
117
- }
118
- };
119
-
120
- // Load initial data
121
- fetchJson('/api/info').then(d => {
122
- $('fn').textContent = d.fileName;
123
- $('sz').textContent = d.width + 'x' + d.height;
124
- frameCount = d.defaults?.frameCount || 1;
125
- fC.textContent = frameCount;
126
- updateFrameDisplay();
127
- loadFrame(0);
128
- });
129
-
130
- // Apply
131
- async function apply() {
132
- $('F').classList.add('hide');
133
- $('L').classList.add('on');
134
- $('G').classList.add('on');
135
-
136
- try {
137
- const result = await postJson('/api/process', {});
138
- if (result.logs) {
139
- result.logs.forEach(l => log('G', l.type[0], l.message));
140
- }
141
-
142
- $('L').classList.remove('on');
143
- $('D').classList.add('on', result.success ? 'ok' : 'err');
144
- $('dT').textContent = result.success ? 'Done' : 'Failed';
145
- $('dM').textContent = result.success ? result.output : result.error;
146
- } catch (e) {
147
- log('G', 'e', e.message);
148
- $('L').classList.remove('on');
149
- $('D').classList.add('on', 'err');
150
- $('dT').textContent = 'Error';
151
- $('dM').textContent = e.message;
152
- }
153
- }
154
- </script>
155
- </body>
156
- </html>