@spark-apps/piclet 1.0.3 → 1.0.5

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.
@@ -58,21 +58,35 @@ input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;hei
58
58
  @keyframes s{to{transform:rotate(360deg)}}
59
59
  .ld span{font-size:12px;color:var(--txt3)}
60
60
 
61
- /* Log - fills available space */
62
- .log{display:none;font:11px/1.4 Consolas,monospace;background:var(--bg2);border-radius:5px;padding:8px;flex:1;min-height:60px;max-height:none;overflow-y:auto}
63
- .log.on{display:block}
61
+ /* Log with preview - 6 lines high with thumbnail */
62
+ .log-container{display:none;gap:8px;height:calc(6 * 1.4em + 16px);flex-shrink:0}
63
+ .log-container.on{display:flex}
64
+ .log{flex:1;font:10px/1.4 Consolas,monospace;background:var(--bg2);border-radius:5px;padding:8px;overflow-y:auto;min-width:0}
64
65
  .log p{margin:2px 0}
65
66
  .log .i{color:var(--txt3)}
66
67
  .log .s{color:var(--ok)}
67
68
  .log .e{color:var(--err)}
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
+ .log-preview.on{display:block}
68
71
 
69
- /* Done state */
70
- .dn{display:none;flex:1;flex-direction:column;align-items:center;justify-content:center;gap:4px}
71
- .dn.on{display:flex}
72
- .dn h4{font-size:14px}
73
- .dn.ok h4{color:var(--ok)}
74
- .dn.err h4{color:var(--err)}
75
- .dn p{font-size:12px;color:var(--txt2);text-align:center}
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}
76
90
 
77
91
  /* Warning/info box */
78
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}
@@ -162,6 +162,11 @@
162
162
  .gif-export-btn{display:none;width:100%;padding:8px 4px;font-size:10px;background:var(--acc);border:none;border-radius:4px;color:#000;cursor:pointer;font-weight:600;margin-top:6px}
163
163
  .gif-export-btn.show{display:block}
164
164
  .gif-export-btn:hover{background:var(--acc2)}
165
+ .frame-actions{display:none;gap:4px;margin-top:6px}
166
+ .frame-actions.show{display:flex}
167
+ .frame-actions button{flex:1;padding:6px 4px;font-size:9px;background:var(--bg3);border:1px solid var(--brd);border-radius:4px;color:var(--txt2);cursor:pointer}
168
+ .frame-actions button:hover{border-color:var(--acc);color:var(--acc)}
169
+ .frame-actions button.del:hover{border-color:#c44;color:#c44}
165
170
 
166
171
  /* Export modal */
167
172
  .export-modal{background:var(--bg2);border:1px solid var(--brd);border-radius:8px;padding:16px 20px;min-width:280px;max-width:320px}
@@ -227,7 +232,12 @@
227
232
  <span>Frames</span>
228
233
  </div>
229
234
  <div class="frame-strip-scroll" id="frameScroll"></div>
235
+ <div class="frame-actions" id="frameActions">
236
+ <button class="del" onclick="deleteSelectedFrame()" title="Delete frame">Del</button>
237
+ <button onclick="replaceSelectedFrame()" title="Replace frame">Replace</button>
238
+ </div>
230
239
  <button class="gif-export-btn" id="gifExportBtn" onclick="showExportModal()">Export</button>
240
+ <input type="file" id="replaceInput" accept=".png,.jpg,.jpeg" style="display:none" onchange="handleReplaceFile(event)">
231
241
  </div>
232
242
 
233
243
  <!-- Left divider (for frame panel) -->
@@ -280,6 +290,12 @@
280
290
  </div>
281
291
  <label class="opt"><input type="checkbox" id="rb-trim" checked onchange="schedulePreview()"><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>Auto-trim edges</label>
282
292
  <label class="opt"><input type="checkbox" id="rb-edges" onchange="schedulePreview()"><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>Border only (preserve inner)</label>
293
+ <label class="opt"><input type="checkbox" id="rb-edgedetect" onchange="toggleEdgeDetect()"><span class="box"><svg viewBox="0 0 12 12"><polyline points="2,6 5,9 10,3"/></svg></span>Feather edges (smoother cuts)</label>
294
+ <div class="tool-row" id="rb-edgeRow" style="display:none">
295
+ <label>Softness</label>
296
+ <input type="range" id="rb-edgestr" min="0" max="100" value="50" oninput="$('rb-edgestrV').textContent=this.value+'%'" onchange="schedulePreview()">
297
+ <span class="val" id="rb-edgestrV">50%</span>
298
+ </div>
283
299
  </div>
284
300
  </div>
285
301
  </div>
@@ -379,15 +395,22 @@
379
395
 
380
396
  <!-- Loading state -->
381
397
  <div class="ld" id="L"><div class="sp"></div><span id="lT">Processing...</span></div>
382
- <!-- Log -->
383
- <div class="log" id="G"></div>
384
- <!-- Done state -->
385
- <div class="dn" id="D">
386
- <h4 id="dT"></h4>
387
- <p id="dM"></p>
388
- <div class="btns" style="width:100%;margin-top:8px">
398
+ <!-- Log with preview -->
399
+ <div class="log-container" id="LC">
400
+ <div class="log" id="G"></div>
401
+ <img class="log-preview" id="LP">
402
+ </div>
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>
411
+ <div class="done-btns">
389
412
  <button class="btn btn-g" onclick="reset()">Back</button>
390
- <button class="btn" onclick="openOutputFolder()" id="openFolderBtn" style="display:none">Open Folder</button>
413
+ <button class="btn" onclick="openOutputFolder()" id="openFolderBtn">Open Folder</button>
391
414
  <button class="btn btn-p" onclick="PicLet.close()">Done</button>
392
415
  </div>
393
416
  </div>
@@ -403,6 +426,55 @@
403
426
  </div>
404
427
  </div>
405
428
 
429
+ <!-- Simplify GIF modal -->
430
+ <div class="modal-overlay" id="simplifyModal" onclick="hideSimplifyModal(event)">
431
+ <div class="export-modal" onclick="event.stopPropagation()">
432
+ <h3>Large GIF Detected</h3>
433
+ <p style="font-size:11px;color:var(--txt2);margin-bottom:12px">
434
+ This GIF has <b id="simplifyFrameCount">0</b> frames which may be slow to process.
435
+ Would you like to simplify it by skipping frames?
436
+ </p>
437
+ <div class="export-options">
438
+ <label class="export-opt" onclick="selectSimplifyOption(1)">
439
+ <input type="radio" name="simplifyType" value="1">
440
+ <span class="radio"></span>
441
+ <div class="export-opt-content">
442
+ <div class="export-opt-title">Keep All Frames</div>
443
+ <div class="export-opt-desc">Use the original GIF as-is</div>
444
+ </div>
445
+ </label>
446
+ <label class="export-opt selected" onclick="selectSimplifyOption(2)">
447
+ <input type="radio" name="simplifyType" value="2" checked>
448
+ <span class="radio"></span>
449
+ <div class="export-opt-content">
450
+ <div class="export-opt-title">Skip Every 2nd Frame</div>
451
+ <div class="export-opt-desc" id="simplifyDesc2">Reduces to ~0 frames</div>
452
+ </div>
453
+ </label>
454
+ <label class="export-opt" onclick="selectSimplifyOption(3)">
455
+ <input type="radio" name="simplifyType" value="3">
456
+ <span class="radio"></span>
457
+ <div class="export-opt-content">
458
+ <div class="export-opt-title">Skip Every 3rd Frame</div>
459
+ <div class="export-opt-desc" id="simplifyDesc3">Reduces to ~0 frames</div>
460
+ </div>
461
+ </label>
462
+ <label class="export-opt" onclick="selectSimplifyOption(4)">
463
+ <input type="radio" name="simplifyType" value="4">
464
+ <span class="radio"></span>
465
+ <div class="export-opt-content">
466
+ <div class="export-opt-title">Skip Every 4th Frame</div>
467
+ <div class="export-opt-desc" id="simplifyDesc4">Reduces to ~0 frames</div>
468
+ </div>
469
+ </label>
470
+ </div>
471
+ <div class="export-modal-btns">
472
+ <button class="cancel" onclick="hideSimplifyModal()">Cancel</button>
473
+ <button class="confirm" onclick="confirmSimplify()">Continue</button>
474
+ </div>
475
+ </div>
476
+ </div>
477
+
406
478
  <!-- Export modal -->
407
479
  <div class="modal-overlay" id="exportModal" onclick="hideExportModal(event)">
408
480
  <div class="export-modal" onclick="event.stopPropagation()">
@@ -645,6 +717,16 @@ function toggleRatioLock() {
645
717
  schedulePreview();
646
718
  }
647
719
 
720
+ function toggleEdgeDetect() {
721
+ const enabled = $('rb-edgedetect').checked;
722
+ $('rb-edgeRow').style.display = enabled ? 'flex' : 'none';
723
+ // Edge detect and preserve inner are mutually exclusive
724
+ if (enabled) {
725
+ $('rb-edges').checked = false;
726
+ }
727
+ schedulePreview();
728
+ }
729
+
648
730
  // Get combined options for all active tools
649
731
  function getOptions() {
650
732
  const opts = { tools: Array.from(activeTools) };
@@ -653,7 +735,9 @@ function getOptions() {
653
735
  opts.removebg = {
654
736
  fuzz: +$('rb-fuzz').value,
655
737
  trim: $('rb-trim').checked,
656
- preserveInner: $('rb-edges').checked
738
+ preserveInner: $('rb-edges').checked,
739
+ edgeDetect: $('rb-edgedetect').checked,
740
+ edgeStrength: +$('rb-edgestr').value
657
741
  };
658
742
  }
659
743
 
@@ -697,18 +781,7 @@ function schedulePreview() {
697
781
 
698
782
  // Show original image
699
783
  async function showOriginal() {
700
- // For GIFs with frames loaded, show selected frame directly
701
- if (isGifFile && gifFrames.length > 0 && gifFrames[selectedFrameIndex]) {
702
- const frame = gifFrames[selectedFrameIndex];
703
- pA.innerHTML = '';
704
- const img = document.createElement('img');
705
- img.src = frame.thumbnail;
706
- pA.appendChild(img);
707
- pI.textContent = `${imageInfo.width} × ${imageInfo.height} (Frame ${selectedFrameIndex + 1} of ${imageInfo.frameCount})`;
708
- updateZoom();
709
- return;
710
- }
711
-
784
+ // Always fetch full-scale preview from API (not the 96px thumbnails)
712
785
  pA.innerHTML = '<div class="mini-sp"></div>';
713
786
  try {
714
787
  const opts = { tools: [], original: true };
@@ -723,7 +796,7 @@ async function showOriginal() {
723
796
  pA.appendChild(img);
724
797
  const frameInfo = isGifFile && imageInfo.frameCount > 1 ? ` (Frame ${selectedFrameIndex + 1} of ${imageInfo.frameCount})` : '';
725
798
  pI.textContent = `${imageInfo.width} × ${imageInfo.height}${frameInfo}`;
726
- updateZoom(); // Apply current zoom
799
+ updateZoom();
727
800
  }
728
801
  } catch (e) {
729
802
  pA.innerHTML = '<span class="placeholder">Failed to load image</span>';
@@ -897,11 +970,24 @@ let isPlaying = false;
897
970
  let playInterval = null;
898
971
  let playSpeed = 100; // ms per frame
899
972
 
900
- function showFrameStrip() {
973
+ function showFrameStrip(skipSimplifyCheck = false) {
974
+ // Check for large GIFs and offer to simplify
975
+ if (!skipSimplifyCheck && imageInfo.frameCount >= LARGE_GIF_THRESHOLD) {
976
+ checkForLargeGif(imageInfo.frameCount, () => {
977
+ // After simplification (or if user keeps all frames), show the strip
978
+ actuallyShowFrameStrip();
979
+ });
980
+ return;
981
+ }
982
+ actuallyShowFrameStrip();
983
+ }
984
+
985
+ function actuallyShowFrameStrip() {
901
986
  $('framePanel').classList.add('show');
902
987
  $('dividerLeft').classList.add('show');
903
988
  $('playControls').classList.add('show');
904
989
  $('gifExportBtn').classList.add('show');
990
+ $('frameActions').classList.add('show');
905
991
  $('frameCount').textContent = imageInfo.frameCount;
906
992
  selectedFrameIndex = 0;
907
993
  gifFrames = [];
@@ -914,6 +1000,7 @@ function hideFrameStrip() {
914
1000
  $('dividerLeft').classList.remove('show');
915
1001
  $('playControls').classList.remove('show');
916
1002
  $('gifExportBtn').classList.remove('show');
1003
+ $('frameActions').classList.remove('show');
917
1004
  stopPlayback();
918
1005
  gifFrames = [];
919
1006
  framePreviewCache = {};
@@ -1025,16 +1112,18 @@ function updatePlaybackFrame() {
1025
1112
  thumb.classList.toggle('selected', i === selectedFrameIndex);
1026
1113
  });
1027
1114
 
1028
- // Update main preview
1029
- const frame = gifFrames[selectedFrameIndex];
1030
- 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) {
1031
1120
  const img = pA.querySelector('img');
1032
1121
  if (img) {
1033
- img.src = frame.thumbnail;
1122
+ img.src = frameSrc;
1034
1123
  } else {
1035
1124
  pA.innerHTML = '';
1036
1125
  const newImg = document.createElement('img');
1037
- newImg.src = frame.thumbnail;
1126
+ newImg.src = frameSrc;
1038
1127
  pA.appendChild(newImg);
1039
1128
  updateZoom();
1040
1129
  }
@@ -1090,16 +1179,8 @@ function selectFrame(index) {
1090
1179
 
1091
1180
  async function showSelectedFrame() {
1092
1181
  if (activeTools.size === 0) {
1093
- // Show original frame
1094
- const frame = gifFrames[selectedFrameIndex];
1095
- if (frame && frame.thumbnail) {
1096
- pA.innerHTML = '';
1097
- const img = document.createElement('img');
1098
- img.src = frame.thumbnail;
1099
- pA.appendChild(img);
1100
- pI.textContent = `Frame ${selectedFrameIndex + 1} of ${imageInfo.frameCount}`;
1101
- updateZoom();
1102
- }
1182
+ // Fetch full-scale frame from API
1183
+ showOriginal();
1103
1184
  } else {
1104
1185
  // Regenerate preview for selected frame
1105
1186
  generatePreview();
@@ -1178,8 +1259,9 @@ async function runExport(opts, message) {
1178
1259
  $('M').classList.add('hide');
1179
1260
  $('B').classList.add('hide');
1180
1261
  $('L').classList.add('on');
1181
- $('G').classList.add('on');
1262
+ $('LC').classList.add('on');
1182
1263
  $('G').innerHTML = '';
1264
+ $('LP').classList.remove('on');
1183
1265
  $('lT').textContent = message;
1184
1266
 
1185
1267
  try {
@@ -1187,10 +1269,198 @@ async function runExport(opts, message) {
1187
1269
  if (result.logs) {
1188
1270
  result.logs.forEach(l => log('G', l.type[0], l.message));
1189
1271
  }
1190
- 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);
1191
1274
  } catch (e) {
1192
1275
  log('G', 'e', e.message);
1193
- showDone(false, 'Error', e.message);
1276
+ await showDone(false, 'Error', e.message);
1277
+ }
1278
+ }
1279
+
1280
+ // ── Frame Delete/Replace ──
1281
+
1282
+ async function deleteSelectedFrame() {
1283
+ if (!isGifFile || gifFrames.length <= 1) {
1284
+ showAlert('Cannot delete the only frame');
1285
+ return;
1286
+ }
1287
+
1288
+ stopPlayback();
1289
+
1290
+ // Show loading on the frame
1291
+ const thumb = document.querySelector(`.frame-thumb[data-index="${selectedFrameIndex}"]`);
1292
+ if (thumb) thumb.classList.add('processing');
1293
+
1294
+ try {
1295
+ const result = await postJson('/api/delete-frame', { frameIndex: selectedFrameIndex });
1296
+
1297
+ if (result.success) {
1298
+ imageInfo.frameCount = result.frameCount;
1299
+ $('frameCount').textContent = result.frameCount;
1300
+
1301
+ // Adjust selected index if needed
1302
+ if (selectedFrameIndex >= result.frameCount) {
1303
+ selectedFrameIndex = result.frameCount - 1;
1304
+ }
1305
+
1306
+ // Reload frame thumbnails
1307
+ gifFrames = [];
1308
+ framePreviewCache = {};
1309
+ loadFrameThumbnails();
1310
+
1311
+ // Show updated frame
1312
+ setTimeout(() => selectFrame(selectedFrameIndex), 100);
1313
+ } else {
1314
+ if (thumb) thumb.classList.remove('processing');
1315
+ showAlert('Failed to delete frame: ' + (result.error || 'Unknown error'));
1316
+ }
1317
+ } catch (err) {
1318
+ if (thumb) thumb.classList.remove('processing');
1319
+ showAlert('Failed to delete frame: ' + err.message);
1320
+ }
1321
+ }
1322
+
1323
+ function replaceSelectedFrame() {
1324
+ if (!isGifFile) return;
1325
+ $('replaceInput').click();
1326
+ }
1327
+
1328
+ async function handleReplaceFile(e) {
1329
+ const file = e.target.files[0];
1330
+ if (!file) return;
1331
+ e.target.value = ''; // Reset for future use
1332
+
1333
+ stopPlayback();
1334
+
1335
+ // Show loading on the frame
1336
+ const thumb = document.querySelector(`.frame-thumb[data-index="${selectedFrameIndex}"]`);
1337
+ if (thumb) thumb.classList.add('processing');
1338
+
1339
+ try {
1340
+ // Read file as base64
1341
+ const reader = new FileReader();
1342
+ reader.onload = async () => {
1343
+ const base64 = reader.result.split(',')[1]; // Remove data URL prefix
1344
+
1345
+ const result = await postJson('/api/replace-frame', {
1346
+ frameIndex: selectedFrameIndex,
1347
+ imageData: base64
1348
+ });
1349
+
1350
+ if (result.success) {
1351
+ // Reload frame thumbnails
1352
+ gifFrames = [];
1353
+ framePreviewCache = {};
1354
+ loadFrameThumbnails();
1355
+
1356
+ // Show updated frame
1357
+ setTimeout(() => selectFrame(selectedFrameIndex), 100);
1358
+ } else {
1359
+ if (thumb) thumb.classList.remove('processing');
1360
+ showAlert('Failed to replace frame: ' + (result.error || 'Unknown error'));
1361
+ }
1362
+ };
1363
+ reader.readAsDataURL(file);
1364
+ } catch (err) {
1365
+ if (thumb) thumb.classList.remove('processing');
1366
+ showAlert('Failed to replace frame: ' + err.message);
1367
+ }
1368
+ }
1369
+
1370
+ // ── GIF Simplification ──
1371
+
1372
+ const LARGE_GIF_THRESHOLD = 50; // Frames threshold for showing simplify prompt
1373
+ let selectedSimplifyOption = 2; // Default to skip every 2nd frame
1374
+ let pendingSimplifyCallback = null;
1375
+
1376
+ function checkForLargeGif(frameCount, callback) {
1377
+ if (frameCount >= LARGE_GIF_THRESHOLD) {
1378
+ showSimplifyModal(frameCount, callback);
1379
+ return true;
1380
+ }
1381
+ if (callback) callback();
1382
+ return false;
1383
+ }
1384
+
1385
+ function showSimplifyModal(frameCount, callback) {
1386
+ pendingSimplifyCallback = callback;
1387
+
1388
+ // Update modal content
1389
+ $('simplifyFrameCount').textContent = frameCount;
1390
+ $('simplifyDesc2').textContent = `Reduces to ~${Math.ceil(frameCount / 2)} frames`;
1391
+ $('simplifyDesc3').textContent = `Reduces to ~${Math.ceil(frameCount / 3)} frames`;
1392
+ $('simplifyDesc4').textContent = `Reduces to ~${Math.ceil(frameCount / 4)} frames`;
1393
+
1394
+ // Reset selection to skip every 2nd (recommended for most cases)
1395
+ selectedSimplifyOption = 2;
1396
+ document.querySelectorAll('#simplifyModal .export-opt').forEach(opt => {
1397
+ const input = opt.querySelector('input');
1398
+ opt.classList.toggle('selected', input.value === '2');
1399
+ input.checked = input.value === '2';
1400
+ });
1401
+
1402
+ $('simplifyModal').classList.add('on');
1403
+ }
1404
+
1405
+ function hideSimplifyModal(e) {
1406
+ if (!e || e.target === $('simplifyModal')) {
1407
+ $('simplifyModal').classList.remove('on');
1408
+ pendingSimplifyCallback = null;
1409
+ }
1410
+ }
1411
+
1412
+ function selectSimplifyOption(value) {
1413
+ selectedSimplifyOption = value;
1414
+ document.querySelectorAll('#simplifyModal .export-opt').forEach(opt => {
1415
+ const input = opt.querySelector('input');
1416
+ opt.classList.toggle('selected', +input.value === value);
1417
+ input.checked = +input.value === value;
1418
+ });
1419
+ }
1420
+
1421
+ async function confirmSimplify() {
1422
+ // Save callback before hiding modal (hideSimplifyModal clears it)
1423
+ const callback = pendingSimplifyCallback;
1424
+ hideSimplifyModal();
1425
+
1426
+ if (selectedSimplifyOption === 1) {
1427
+ // Keep all frames - just continue
1428
+ if (callback) callback();
1429
+ return;
1430
+ }
1431
+
1432
+ // Show loading state
1433
+ pA.innerHTML = '<div class="mini-sp"></div>';
1434
+ pI.textContent = 'Simplifying GIF...';
1435
+
1436
+ try {
1437
+ const result = await postJson('/api/simplify-gif', { skipFactor: selectedSimplifyOption });
1438
+
1439
+ if (result.success) {
1440
+ // Update image info with simplified version
1441
+ imageInfo.width = result.width;
1442
+ imageInfo.height = result.height;
1443
+ imageInfo.frameCount = result.frameCount;
1444
+
1445
+ // Update display
1446
+ $('origSize').textContent = imageInfo.width + '×' + imageInfo.height;
1447
+
1448
+ // Refresh frame strip (skip simplify check since we just simplified)
1449
+ currentFrameCount = result.frameCount;
1450
+ if (result.frameCount > 1) {
1451
+ showFrameStrip(true); // Skip simplify check
1452
+ } else {
1453
+ hideFrameStrip();
1454
+ }
1455
+
1456
+ if (callback) callback();
1457
+ } else {
1458
+ showAlert('Failed to simplify GIF: ' + (result.error || 'Unknown error'));
1459
+ showOriginal();
1460
+ }
1461
+ } catch (err) {
1462
+ showAlert('Failed to simplify GIF: ' + err.message);
1463
+ showOriginal();
1194
1464
  }
1195
1465
  }
1196
1466
 
@@ -1373,8 +1643,9 @@ async function apply() {
1373
1643
  $('M').classList.add('hide');
1374
1644
  $('B').classList.add('hide');
1375
1645
  $('L').classList.add('on');
1376
- $('G').classList.add('on');
1646
+ $('LC').classList.add('on');
1377
1647
  $('G').innerHTML = '';
1648
+ $('LP').classList.remove('on');
1378
1649
 
1379
1650
  const toolNames = Array.from(activeTools).map(t => {
1380
1651
  const names = { removebg: 'Remove BG', scale: 'Scale', icons: 'Icons', storepack: 'Store Assets' };
@@ -1389,20 +1660,22 @@ async function apply() {
1389
1660
  result.logs.forEach(l => log('G', l.type[0], l.message));
1390
1661
  }
1391
1662
 
1392
- 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);
1393
1665
  } catch (e) {
1394
1666
  log('G', 'e', e.message);
1395
- showDone(false, 'Error', e.message);
1667
+ await showDone(false, 'Error', e.message);
1396
1668
  }
1397
1669
  }
1398
1670
 
1399
1671
  // Reset to main view
1400
1672
  function reset() {
1401
- $('D').classList.remove('on', 'ok', 'err');
1402
- $('G').classList.remove('on');
1673
+ $('DC').classList.remove('on', 'ok', 'err');
1674
+ $('donePreview').classList.remove('on');
1675
+ $('LC').classList.remove('on');
1676
+ $('LP').classList.remove('on');
1403
1677
  $('M').classList.remove('hide');
1404
1678
  $('B').classList.remove('hide');
1405
- $('openFolderBtn').style.display = 'none';
1406
1679
  lastOutputSuccess = false;
1407
1680
  }
1408
1681
 
@@ -1420,21 +1693,75 @@ function openOutputFolder() {
1420
1693
  postJson('/api/open-folder', {});
1421
1694
  }
1422
1695
 
1423
- // Show done state with optional folder button
1424
- function showDone(success, title, message) {
1696
+ // Show output preview thumbnail in log area
1697
+ async function showLogPreview() {
1698
+ try {
1699
+ const result = await fetchJson('/api/output-preview');
1700
+ if (result.success && result.imageData) {
1701
+ $('LP').src = result.imageData;
1702
+ $('LP').classList.add('on');
1703
+ }
1704
+ } catch { /* ignore */ }
1705
+ }
1706
+
1707
+ // Show done state (replaces log container)
1708
+ async function showDone(success, title, message) {
1425
1709
  $('L').classList.remove('on');
1426
- $('D').classList.add('on', success ? 'ok' : 'err');
1710
+ $('LC').classList.remove('on'); // Hide log container
1711
+ $('DC').classList.add('on', success ? 'ok' : 'err');
1712
+ $('DC').classList.remove(success ? 'err' : 'ok');
1427
1713
  $('dT').textContent = title;
1428
1714
  $('dM').textContent = message;
1429
1715
  lastOutputSuccess = success;
1430
1716
  $('openFolderBtn').style.display = success ? '' : 'none';
1431
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
+ }
1432
1743
  }
1433
1744
 
1434
1745
  // Init
1435
1746
  setupDragDrop();
1436
1747
  setupDivider();
1437
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
+
1438
1765
  // Resizable dividers
1439
1766
  function setupDivider() {
1440
1767
  const divider = $('divider');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spark-apps/piclet",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Lightweight image tools for content creators",
5
5
  "type": "module",
6
6
  "bin": {