-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.js
More file actions
1773 lines (1671 loc) · 94.3 KB
/
Copy pathapp.js
File metadata and controls
1773 lines (1671 loc) · 94.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/* SQUISH — Puddy Studios.
* Client-side "compress to a target file size" tool: images, GIF, video, audio, PDF, SVG.
* All compression runs client-side via canvas/wasm.
*
* Method: probe several candidates, model size-vs-setting, predict the setting that
* lands JUST under the target, verify, keep the closest fit (never over).
* Image/SVG : canvas re-encode, qualities probed in parallel + interpolated.
* GIF : gifsicle-wasm, lossy points probed sequentially + interpolated.
* Video/audio : @ffmpeg/core, bitrate predicted from duration + probed.
* PDF : pdf.js raster + pdf-lib re-embed.
*/
(function () {
'use strict';
// ---------- constants
// Decimal units (1 KB = 1000 B, 1 MB = 1,000,000 B) to match what file
// managers report (macOS Finder, Windows "size", upload dialogs). If we used
// binary 1024, a "200 KB" target = 204,800 B, which Finder then shows as
// ~205 KB — i.e. "bigger than I asked for". Decimal keeps the typed number
// as the real on-disk ceiling the user sees.
const KB = 1000, MB = 1000 * 1000;
const GIFSICLE_CDN = 'https://cdn.jsdelivr.net/npm/gifsicle-wasm-browser/dist/gifsicle.min.js';
// ffmpeg.wasm (video + audio). Single-threaded core: no SharedArrayBuffer, so
// no COOP/COEP headers required on a plain static host (slower than the MT
// core, but works anywhere). Lazily loaded on the first AV file.
//
// The tiny UMD loader + worker are SELF-HOSTED (same origin) because ffmpeg.wasm
// spawns an internal Worker and browsers block constructing a Worker from a
// cross-origin URL (the esm.sh ESM build resolves its worker from import.meta.url
// and ignores classWorkerURL). The heavy ~30MB core stays on the CDN (fetched via
// toBlobURL, which is CORS-fine) and is runtime-cached by the service worker.
// Relative so SQUISH works mounted at any subpath (resolves against the page
// URL, which the index.html trailing-slash guard normalizes to a directory).
const FFMPEG_VENDOR = 'vendor/ffmpeg';
const FFMPEG_CORE = 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/umd';
// PDF: pdf.js (UMD global `pdfjsLib`) RENDERS pages to a canvas; pdf-lib (UMD global
// `PDFLib`) BUILDS/embeds. Both lazily loaded on the first PDF or ->PDF job.
const PDFJS_CDN = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build';
const PDFLIB_CDN = 'https://cdn.jsdelivr.net/npm/pdf-lib@1.17.1/dist/pdf-lib.min.js';
const BIG_FILE = 75 * MB; // soft warning threshold (images)
const AV_HARD_MAX = 250 * MB; // warn hard above this (in-memory transcode ceiling); never refuses
const AV_SLOW = 60 * MB; // warn AV above this
const MIN_DIM = 16; // don't downscale below this
// ---------- dom
const $ = (id) => document.getElementById(id);
const stageInput = $('stage-input');
const stageConfig = $('stage-config');
const stageResult = $('stage-result');
const drop = $('drop');
const fileInput = $('file-input');
const inputError = $('input-error');
const origPreview = $('orig-preview');
const metaName = $('meta-name');
const metaType = $('meta-type');
const metaSize = $('meta-size');
const metaDims = $('meta-dims');
const metaDimsLabel = $('meta-dims-label');
const targetValue = $('target-value');
const unitBtns = Array.from(document.querySelectorAll('.unit-btn'));
const chips = Array.from(document.querySelectorAll('.chip'));
const targetHint = $('target-hint');
const formatControl = $('format-control');
const formatHint = $('format-hint');
const modeControl = $('mode-control');
const modeHint = $('mode-hint');
const targetControl = $('target-control');
const optimizeBtn = $('optimize');
const resetBtn1 = $('reset-1');
const progressWrap = document.querySelector('.progress-wrap');
const barFill = $('bar-fill');
const progressText = $('progress-text');
const outPreview = $('out-preview');
const resOrig = $('res-orig');
const resNew = $('res-new');
const resRatio = $('res-ratio');
const resRatioLabel = $('res-ratio-label');
const resTarget = $('res-target');
const resParams = $('res-params');
const fileCard = document.querySelector('.filecard');
const resNote = $('res-note');
const resNoteText = $('res-note-text');
const resRecommend = $('res-recommend');
const downloadLink = $('download');
const backBtn = $('back-btn');
const resetBtn2 = $('reset-2');
// ---------- state
let file = null;
let kind = null; // 'image' | 'gif' | 'video' | 'audio' | 'pdf' | 'svg'
let dims = null; // {w,h}
let avDuration = 0; // seconds, for video/audio bitrate prediction
let unit = 'MB';
// outputs per kind (see FORMATS): image same|jpeg|png|webp|pdf · gif same|gif|mp4|mov|mkv ·
// video same|mp4|mov|mkv|gif · audio same|mp3|m4a · pdf same|pdf|jpeg|png|webp · svg same|png|jpeg|webp|pdf
let outFormat = 'same';
let busy = false;
let aborted = false; // set by CANCEL mid-job; engines check it + ffmpeg is terminated
let lastOutUrl = null;
let lastPreviewUrl = null; // input-preview object URL, revoked on each new intake/reset
let recTarget = null; // {value, unit} recommended floor when a target is too aggressive
let mode = 'size'; // 'size' (compress to a target) | 'max' (best-quality conversion, no target)
// ---------- helpers
function humanSize(bytes) {
if (bytes == null) return '-';
if (bytes >= MB) return (bytes / MB).toFixed(bytes >= 10 * MB ? 0 : 2) + ' MB';
if (bytes >= KB) return (bytes / KB).toFixed(bytes >= 10 * KB ? 0 : 1) + ' KB';
return bytes + ' B';
}
function targetBytes() {
const v = parseFloat(targetValue.value);
if (!isFinite(v) || v <= 0) return NaN;
return Math.round(v * (unit === 'MB' ? MB : KB));
}
// A clean 1/2/5 x 10^n size (decimal KB/MB), snapped to the nearest tier.
function niceSize(bytes) {
const inMB = bytes >= MB;
const u = inMB ? MB : KB;
const v = bytes / u;
const pow = Math.pow(10, Math.floor(Math.log10(v || 1)));
const norm = v / pow; // 1..<10
const snapped = norm < 1.5 ? 1 : norm < 3.5 ? 2 : norm < 7.5 ? 5 : 10;
let value = +(snapped * pow).toFixed(inMB ? 1 : 0);
if (!value) value = inMB ? 0.1 : 1;
return { value, unit: inMB ? 'MB' : 'KB', label: `${value} ${inMB ? 'MB' : 'KB'}`, bytes: value * u };
}
// After a fit required heavy degradation, recommend a LARGER but still-real
// target. Invariants (guaranteed): usedTarget < recommendation < original - a
// genuine quality lift that is still a true compression, NEVER at/above the
// original size (which would just hand the file back unchanged). Returns null
// when the target was not aggressive enough for a sensible larger suggestion.
function recommendLarger(origBytes, usedTargetBytes) {
const t = (isFinite(usedTargetBytes) && usedTargetBytes > 0) ? usedTargetBytes : 0;
if (t >= origBytes * 0.5) return null; // already past half the original - no useful headroom
let cand = Math.max(origBytes * 0.5, t * 3); // ~half the original, well clear of the target
cand = Math.min(cand, origBytes * 0.6); // but never close to the original
let rec = niceSize(cand);
// Bulletproof the invariants against snap rounding.
let guard = 0;
while (rec.bytes >= origBytes && guard++ < 8) rec = niceSize(rec.bytes * 0.75);
if (rec.bytes <= t) rec = niceSize(t * 2);
if (rec.bytes >= origBytes) return null; // give up rather than mislead
return rec;
}
function setProgress(pct, text) {
progressWrap.hidden = false;
const p = Math.max(0, Math.min(100, pct));
if (barFill) barFill.style.width = p + '%';
if (barFill && barFill.parentElement) barFill.parentElement.setAttribute('aria-valuenow', Math.round(p));
if (progressText) progressText.textContent = text || '';
}
function showStage(which) {
// The drop box stays visible on every stage (below the active panel) so a
// new file can come in at any point; config and result stay exclusive.
stageInput.classList.remove('hidden');
stageConfig.classList.toggle('hidden', which !== 'config');
stageResult.classList.toggle('hidden', which !== 'result');
}
function showError(msg) {
inputError.textContent = msg;
inputError.hidden = !msg;
}
function extFor(mime) {
if (mime === 'image/jpeg') return 'jpg';
if (mime === 'image/webp') return 'webp';
if (mime === 'image/png') return 'png';
if (mime === 'image/gif') return 'gif';
if (mime === 'video/mp4') return 'mp4';
if (mime === 'video/quicktime') return 'mov';
if (mime === 'video/x-matroska') return 'mkv';
if (mime === 'video/webm') return 'webm';
if (mime === 'audio/mpeg') return 'mp3';
if (mime === 'audio/mp4') return 'm4a';
if (mime === 'application/pdf') return 'pdf';
if (mime === 'image/svg+xml') return 'svg';
return 'bin';
}
function baseName(name) {
return (name || 'file').replace(/\.[^.]+$/, '');
}
// ---------- intake
// Broad intake. Video/audio go through ffmpeg.wasm (a very wide demuxer set);
// images go through the browser. MIME first, then a filename-extension fallback
// for the many files that arrive with an empty or wrong type. Only formats that
// actually DECODE in this stack are exposed - see the tested list in the docs.
const VIDEO_EXT = new Set(['mp4', 'm4v', 'mov', 'qt', 'webm', 'mkv', 'avi', 'flv', 'f4v', '3gp', '3g2', 'mpg', 'mpeg', 'mpe', 'm1v', 'm2v', 'ts', 'm2ts', 'mts', 'wmv', 'ogv', 'vob', 'asf', 'divx']);
const AUDIO_EXT = new Set(['mp3', 'wav', 'wave', 'm4a', 'm4b', 'aac', 'adts', 'ogg', 'oga', 'opus', 'flac', 'aif', 'aiff', 'aifc', 'wma', 'amr', 'ac3', 'mka', 'weba', 'caf']);
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'jpe', 'jfif', 'png', 'apng', 'webp', 'bmp', 'dib', 'ico', 'avif']);
function classify(f) {
if (!f) return null;
const t = (f.type || '').toLowerCase();
const ext = ((f.name || '').toLowerCase().match(/\.([a-z0-9]+)$/) || [])[1] || '';
if (t === 'application/pdf' || ext === 'pdf') return 'pdf';
if (t === 'image/svg+xml' || ext === 'svg') return 'svg';
if (t === 'image/gif' || ext === 'gif') return 'gif';
if (t.startsWith('video/') || VIDEO_EXT.has(ext)) return 'video';
if (t.startsWith('audio/') || AUDIO_EXT.has(ext)) return 'audio';
if (t.startsWith('image/') || IMAGE_EXT.has(ext)) return 'image';
return null;
}
async function handleFile(f) {
// Never mutate the shared file/kind/dims state while a job is reading it - that would
// corrupt the running job's output + the result readout. Finish or cancel first.
if (busy) { showError('One file at a time - Finish or cancel the current job first.'); return; }
showError('');
const k = classify(f);
if (!k) {
showStage('input'); // surface the error even if a file was dropped from the result stage
showError('Unsupported file. SQUISH handles PDF, SVG, images (JPG, PNG, WEBP, GIF, BMP), video (MP4, MOV, MKV, AVI, WEBM, and more) and audio (MP3, M4A, WAV, FLAC, and more).');
return;
}
// Free the previous result + preview blobs before loading the new file (any stage).
if (lastOutUrl) { URL.revokeObjectURL(lastOutUrl); lastOutUrl = null; }
if (lastPreviewUrl) { URL.revokeObjectURL(lastPreviewUrl); lastPreviewUrl = null; }
try {
file = f; kind = k; dims = null; avDuration = 0;
metaName.textContent = f.name || 'file';
metaName.title = f.name || '';
metaType.textContent = badgeLabelFor(f).toUpperCase(); // never blank, even for empty-MIME files
metaSize.textContent = humanSize(f.size);
// preview + dimensions/duration
if (k === 'image' || k === 'gif' || k === 'svg') {
clearInputBadge();
origPreview.style.display = '';
origPreview.onerror = () => showInputBadge(f); // undecodable image (tiff/heic/bad svg) -> badge
const pv = (k === 'svg' && f.type !== 'image/svg+xml') ? new Blob([await f.arrayBuffer()], { type: 'image/svg+xml' }) : f;
lastPreviewUrl = URL.createObjectURL(pv);
origPreview.src = lastPreviewUrl;
try { const d = await readDimensions(f); dims = d; setDimsLabel('DIMENSIONS', d ? `${d.w} × ${d.h}` : '-'); }
catch (_) { setDimsLabel('DIMENSIONS', '-'); }
if (k === 'gif') { try { avDuration = gifDurationSeconds(await f.arrayBuffer()) || 2; } catch (_) { avDuration = 2; } }
} else if (k === 'pdf') {
showInputBadge(f);
setDimsLabel('FORMAT', 'PDF DOCUMENT');
} else {
showInputBadge(f);
const m = await readMediaMeta(f, k);
if (m) { dims = m.w ? { w: m.w, h: m.h } : null; avDuration = m.duration || 0; }
const durStr = avDuration ? formatDuration(avDuration) : '-';
if (k === 'video') setDimsLabel('VIDEO', `${dims ? dims.w + '×' + dims.h + ' · ' : ''}${durStr}`);
else setDimsLabel('DURATION', durStr);
}
// sensible default target: ~half, in a friendly unit
if (f.size >= MB) { unit = 'MB'; targetValue.value = Math.max(0.1, +(f.size / MB / 2).toFixed(1)); }
else { unit = 'KB'; targetValue.value = Math.max(1, Math.round(f.size / KB / 2)); }
syncUnitButtons();
// output-format selector per media kind (GIF can now stay GIF or convert to video)
outFormat = 'same';
buildFormatControl(k);
formatControl.hidden = false;
syncFmtButtons();
updateFormatHint();
mode = 'size';
syncMode();
showStage('config');
updateTargetHint();
// Never refuse - just be honest about big files, then let the user go for it.
const av = (k === 'video' || k === 'audio');
if (av && f.size >= AV_HARD_MAX) {
targetHint.textContent = `Big file (${humanSize(f.size)}). SQUISH runs 100% on your device, so this one may be slow or bump your browser's memory limit - But go for it.`;
targetHint.classList.add('warn');
} else if (f.size >= (av ? AV_SLOW : BIG_FILE)) {
targetHint.textContent = 'Heads up: large file. SQUISH runs 100% on your device, so it may take a while.';
targetHint.classList.add('warn');
}
} catch (e) {
showStage('input');
showError('Could not read that file - It may have moved or become unreadable. Try selecting it again.');
}
}
function readDimensions(f) {
return new Promise((resolve) => {
const img = new Image();
const u = URL.createObjectURL(f);
img.onload = () => { URL.revokeObjectURL(u); resolve({ w: img.naturalWidth, h: img.naturalHeight }); };
img.onerror = () => { URL.revokeObjectURL(u); resolve(null); };
img.src = u;
});
}
// Read duration (and dims for video) via a media element.
function readMediaMeta(f, k) {
// Display-only (dims + duration in the config readout). The REAL duration used for
// encoding is re-read from ffmpeg via ensureDuration() at squish time, so this may
// safely resolve null. It MUST never hang the load: a backgrounded tab defers media
// element loading (loadedmetadata never fires) and some containers fire neither
// loadedmetadata nor error - without this timeout handleFile would await forever and
// the config stage would never appear. Time out to null and let the load proceed.
return new Promise((resolve) => {
const el = document.createElement(k === 'audio' ? 'audio' : 'video');
let done = false;
const finish = (r) => { if (done) return; done = true; clearTimeout(timer); try { URL.revokeObjectURL(el.src); } catch (_) {} resolve(r); };
const timer = setTimeout(() => finish(null), 8000);
el.preload = 'metadata';
el.onloadedmetadata = () => finish({ w: el.videoWidth || 0, h: el.videoHeight || 0, duration: isFinite(el.duration) ? el.duration : 0 });
el.onerror = () => finish(null);
el.src = URL.createObjectURL(f);
});
}
function setDimsLabel(label, val) {
if (metaDimsLabel) metaDimsLabel.textContent = label;
metaDims.textContent = val;
}
function formatDuration(s) {
s = Math.round(s); const m = Math.floor(s / 60), ss = s % 60;
return `${m}:${String(ss).padStart(2, '0')}`;
}
// Output-format selector options per media kind.
const FORMATS = {
image: [['same', 'SAME'], ['jpeg', 'JPG'], ['png', 'PNG'], ['webp', 'WEBP'], ['pdf', 'PDF']],
gif: [['same', 'SAME'], ['gif', 'GIF'], ['mp4', 'MP4'], ['mov', 'MOV'], ['mkv', 'MKV']],
video: [['same', 'SAME'], ['mp4', 'MP4'], ['mov', 'MOV'], ['mkv', 'MKV'], ['gif', 'GIF']],
audio: [['same', 'SAME'], ['mp3', 'MP3'], ['m4a', 'M4A']],
pdf: [['same', 'SAME'], ['pdf', 'PDF'], ['jpeg', 'JPG'], ['png', 'PNG'], ['webp', 'WEBP']],
svg: [['same', 'SAME'], ['png', 'PNG'], ['jpeg', 'JPG'], ['webp', 'WEBP'], ['pdf', 'PDF']],
};
function buildFormatControl(k) {
const seg = formatControl.querySelector('.seg');
while (seg.firstChild) seg.removeChild(seg.firstChild);
(FORMATS[k] || FORMATS.image).forEach(([v, l]) => {
const btn = document.createElement('button');
btn.type = 'button'; btn.className = 'seg-btn'; btn.dataset.fmt = v; btn.textContent = l;
seg.appendChild(btn);
});
}
function updateTargetHint() {
targetHint.classList.remove('warn');
const t = targetBytes();
if (!file) { targetHint.textContent = ' '; optimizeBtn.disabled = true; return; }
if (isNaN(t)) { targetHint.textContent = 'Enter a target size.'; optimizeBtn.disabled = true; return; }
if (t >= file.size) {
targetHint.textContent = `That's larger than the original (${humanSize(file.size)}), so we'll just hand it back unchanged.`;
optimizeBtn.disabled = false; return;
}
const pct = ((t / file.size) * 100).toFixed(0);
targetHint.textContent = `Original is ${humanSize(file.size)}. Target ${humanSize(t)} ≈ ${pct}% of original.`;
optimizeBtn.disabled = false;
}
function syncUnitButtons() {
unitBtns.forEach((b) => { const on = b.dataset.unit === unit; b.classList.toggle('is-on', on); b.setAttribute('aria-pressed', on); });
}
// Mode: 'size' (compress to a typed target) vs 'max' (best-quality conversion).
function syncMode() {
modeControl.querySelectorAll('.mode-btn').forEach((b) => { const on = b.dataset.mode === mode; b.classList.toggle('is-on', on); b.setAttribute('aria-pressed', on); });
const isMax = mode === 'max';
targetControl.hidden = isMax;
optimizeBtn.textContent = isMax ? 'CONVERT' : 'SQUISH IT';
if (isMax) {
modeHint.textContent = 'Best possible quality - Lossless where the format allows. No size limit.';
optimizeBtn.disabled = false;
} else {
modeHint.textContent = ' ';
updateTargetHint(); // re-evaluates the button enabled-state from the target field
}
}
function syncFmtButtons() {
formatControl.querySelectorAll('.seg-btn').forEach((b) => { const on = b.dataset.fmt === outFormat; b.classList.toggle('is-on', on); b.setAttribute('aria-pressed', on); });
}
function resolveOutMime() {
// explicit choices
if (outFormat === 'jpeg') return 'image/jpeg';
if (outFormat === 'png') return 'image/png';
if (outFormat === 'webp') return 'image/webp';
if (outFormat === 'gif') return 'image/gif';
if (outFormat === 'mp4') return 'video/mp4';
if (outFormat === 'mov') return 'video/quicktime';
if (outFormat === 'mkv') return 'video/x-matroska';
if (outFormat === 'mp3') return 'audio/mpeg';
if (outFormat === 'm4a') return 'audio/mp4';
if (outFormat === 'pdf') return 'application/pdf';
// 'same' -> keep the input format (normalize exotic/unsupported containers to a safe default)
if (kind === 'pdf') return 'application/pdf';
if (kind === 'svg') return 'image/svg+xml';
if (kind === 'gif') return 'image/gif';
if (kind === 'image') return ['image/jpeg', 'image/png', 'image/webp'].includes(file.type) ? file.type : 'image/webp';
if (kind === 'video') return file.type === 'video/quicktime' ? 'video/quicktime' : (file.type === 'video/x-matroska' || file.type === 'video/mkv') ? 'video/x-matroska' : 'video/mp4';
if (kind === 'audio') return file.type === 'audio/mp4' ? 'audio/mp4' : 'audio/mpeg';
return file.type;
}
function updateFormatHint() {
if (!file) return;
const m = resolveOutMime();
let msg;
if (m === 'application/pdf') {
msg = kind === 'pdf'
? 'PDF - Recompressed to hit your target. Pages are re-rendered, so it stays sharper the larger the target.'
: 'PDF - Wraps your image in a one-page PDF, compressed to your target.';
} else if (kind === 'pdf') {
const lbl = m === 'image/png' ? 'PNG' : m === 'image/webp' ? 'WebP' : 'JPG';
msg = lbl + ' - Renders your PDF pages into one ' + lbl + ' image, sized to hit your target.';
} else if (kind === 'svg') {
msg = m === 'image/svg+xml'
? 'SVG - Optimized in place (whitespace + metadata stripped), stays sharp vector at any size.'
: (m === 'image/png' ? 'PNG' : m === 'image/webp' ? 'WebP' : 'JPG') + ' - Renders your vector SVG to a raster image at your target size.';
} else if (kind === 'image') {
if (m === 'image/png') msg = 'PNG is lossless - SQUISH hits your target by resizing, not by lowering quality.';
else if (m === 'image/jpeg') msg = 'JPEG - Smallest for photos, but drops transparency.';
else msg = 'WebP - Best size-for-quality, keeps transparency.';
} else if (m === 'image/gif') {
msg = kind === 'gif'
? 'GIF - Optimized in place with a smart palette. Never over your target.'
: 'GIF - Turns your video into an animated GIF (palette-matched, frame rate capped to hit your size).';
} else if (m === 'video/mp4' || m === 'video/quicktime' || m === 'video/x-matroska') {
const label = m === 'video/quicktime' ? 'MOV' : m === 'video/x-matroska' ? 'MKV' : 'MP4';
msg = kind === 'gif'
? 'Turns your GIF into a real ' + label + ' video - Far smaller than the GIF, and it plays everywhere.'
: (m === 'video/quicktime'
? 'MOV (H.264) - QuickTime container, same quality as MP4. Great for Apple apps.'
: m === 'video/x-matroska'
? 'MKV (H.264) - Matroska container, plays in VLC and most modern players.'
: 'MP4 (H.264) - Universal playback. SQUISH sets the bitrate to hit your size.');
} else {
msg = m === 'audio/mp4' ? 'M4A (AAC) - Efficient, great for music.' : 'MP3 - Universal audio. SQUISH sets the bitrate to hit your size.';
}
const conv = (outFormat !== 'same' && extFor(m) !== extFor(file.type))
? ` Converting ${(file.type.split('/')[1] || '').toUpperCase()} → ${extFor(m).toUpperCase()}.`
: '';
formatHint.textContent = msg + conv;
}
// ---------- image pipeline
function encodeCanvas(bitmap, w, h, mime, quality) {
const c = document.createElement('canvas');
c.width = w; c.height = h;
const ctx = c.getContext('2d');
if (mime === 'image/jpeg') { ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, w, h); } // jpeg has no alpha
ctx.drawImage(bitmap, 0, 0, w, h);
return new Promise((resolve) => {
c.toBlob((blob) => {
if (blob) return resolve(blob);
// fallback if webp unsupported for toBlob
c.toBlob((b2) => resolve(b2), 'image/jpeg', quality);
}, mime, quality);
});
}
// Track every candidate we actually encode; expose the closest fit UNDER target
// (largest size <= target) and the overall smallest as a best-effort fallback.
function makeTracker(target) {
let best = null, smallest = null;
return {
add(blob, meta) {
if (!blob) return null;
const rec = { blob, size: blob.size, meta };
if (!smallest || rec.size < smallest.size) smallest = rec;
if (rec.size <= target && (!best || rec.size > best.size)) best = rec;
return rec;
},
get best() { return best; },
get smallest() { return smallest; },
};
}
// Interpolate the parameter value that should land at `target`, from measured
// (x, size) points. Direction-agnostic (quality up = size up, lossy up = size
// down, scale up = size up). Returns null when target is not bracketed.
function predict(points, target) {
const pts = points.slice().sort((a, b) => a.x - b.x);
for (let i = 0; i < pts.length - 1; i++) {
const a = pts[i], b = pts[i + 1];
const lo = Math.min(a.size, b.size), hi = Math.max(a.size, b.size);
if (target >= lo && target <= hi && a.size !== b.size) {
return a.x + (target - a.size) / (b.size - a.size) * (b.x - a.x);
}
}
return null;
}
async function squishImage(target, onProgress) {
let bitmap;
try { bitmap = await createImageBitmap(file); }
catch (_) {
bitmap = await new Promise((res, rej) => {
const im = new Image(); const u = URL.createObjectURL(file);
im.onload = () => { URL.revokeObjectURL(u); res(im); };
im.onerror = () => { URL.revokeObjectURL(u); rej(new Error('This file would not decode as an image - It may be damaged or mislabeled')); };
im.src = u;
});
}
const baseW = bitmap.width || dims?.w, baseH = bitmap.height || dims?.h;
const outMime = resolveOutMime();
const fromPng = file.type === 'image/png' && outMime !== 'image/png';
const fmtName = (outMime === 'image/webp' ? 'WebP' : outMime === 'image/png' ? 'PNG' : 'JPEG') + (fromPng ? ' (from PNG)' : '');
const track = makeTracker(target);
return outMime === 'image/png'
? squishPng(bitmap, baseW, baseH, target, track, onProgress)
: squishLossy(bitmap, baseW, baseH, outMime, fmtName, target, track, onProgress);
}
// JPEG / WebP: encode several qualities IN PARALLEL (multiple versions at once),
// model size-vs-quality, predict the quality that lands just under target, verify
// with a tight parallel pair, keep the closest fit. Downscale only when even the
// lowest quality overshoots.
async function squishLossy(bitmap, baseW, baseH, outMime, fmtName, target, track, onProgress) {
const encAt = (q, scale) => {
const w = Math.max(MIN_DIM, Math.round(baseW * scale));
const h = Math.max(MIN_DIM, Math.round(baseH * scale));
return encodeCanvas(bitmap, w, h, outMime, q).then((b) => { track.add(b, { q, scale }); return { q, scale, size: b.size }; });
};
const SCALES = [1, 0.82, 0.66, 0.5, 0.36, 0.25, 0.16, 0.1];
const SWEEP = [0.35, 0.60, 0.80, 0.92];
const QMAX = outMime === 'image/webp' ? 0.9995 : 1; // WebP q=1.0 is the lossless jump; stay just below it during the lossy climb
// Binary-search quality at a fixed scale for the LARGEST encode still <= target.
// `lo` must already fit, `hi` must overshoot. The tracker keeps the closest fit.
const closeQ = async (scale, lo, hi, iters) => {
for (let i = 0; i < iters; i++) { const q = (lo + hi) / 2; const r = await encAt(q, scale); if (r.size <= target) lo = q; else hi = q; }
};
for (let si = 0; si < SCALES.length; si++) {
const scale = SCALES[si];
onProgress(14 + si * 13, scale < 1 ? `Probing at ${Math.round(scale * 100)}% (parallel)…` : 'Probing quality (parallel)…');
const probes = await Promise.all(SWEEP.map((q) => encAt(q, scale))); // <-- multiple versions at once
const lowQ = probes[0], highQ = probes[probes.length - 1];
if (lowQ.size > target) { if (si === SCALES.length - 1) break; continue; } // even min quality too big -> shrink
onProgress(18 + si * 13, 'Honing in on the target…');
if (highQ.size <= target) { // fits easily -> USE THE BUDGET: climb to the true ceiling
const top = await encAt(1, scale); // lossless WebP / max-quality JPEG (a discrete jump above the lossy range)
if (top.size > target) await closeQ(scale, highQ.q, QMAX, 8); // ceiling overshoots -> climb the lossy range to its largest fit
break; // track.best holds the largest fit (lossless if it fit, else the lossy ceiling)
}
// Target sits inside the lossy range. Tighten the bracket from the probes, then
// binary-search quality right up to the target for the closest possible fit.
let lo = lowQ.q, hi = highQ.q;
for (const p of probes) { if (p.size <= target) { if (p.q > lo) lo = p.q; } else if (p.q < hi) hi = p.q; }
await closeQ(scale, lo, hi, 8);
break;
}
// Quality is quantized, so even the closest in-range fit can leave headroom. Bump
// quality a notch (it overshoots at this scale) and trim the scale a hair to fill the
// gap - trading a sliver of resolution for a higher-quality encode nearer the target.
if (track.best && track.best.meta.q < QMAX && (target - track.best.size) / target > 0.02) {
const m = track.best.meta;
const bumpQ = Math.min(QMAX, m.q + 0.06);
if (bumpQ > m.q + 1e-4) {
onProgress(92, 'Squeezing closer…');
const hp = await encAt(bumpQ, m.scale);
if (hp.size > target) { // higher quality overshoots -> shrink scale (at most 20%) until it fits
let lo = Math.max(MIN_DIM / baseW, m.scale * 0.8), hi = m.scale;
for (let i = 0; i < 7; i++) { const s = (lo + hi) / 2; const r = await encAt(bumpQ, s); if (r.size <= target) lo = s; else hi = s; }
}
}
}
// GUARANTEE under target: if nothing fit yet, shrink dimensions at minimum
// quality until it does. SQUISH NEVER delivers a file over the requested size.
if (!track.best) {
let s = SCALES[SCALES.length - 1], guard = 0;
while (!track.best && guard++ < 16 && Math.round(baseW * s) > MIN_DIM) {
s = Math.max(MIN_DIM / baseW, s * 0.7);
await encAt(0.05, s);
}
}
if (track.best) {
const m = track.best.meta;
const qLabel = (outMime === 'image/webp' && m.q >= 1) ? 'lossless' : `quality ${Math.round(m.q * 100)}%`;
return { blob: track.best.blob, params: `${fmtName}, ${qLabel}${m.scale < 1 ? `, scaled ${Math.round(m.scale * 100)}%` : ''}`, warn: m.scale < 0.5 || m.q < 0.4, mime: outMime };
}
const s = track.smallest; // only if even a 16px image exceeds target (essentially impossible)
return { blob: s.blob, params: `${fmtName}, quality ${Math.round(s.meta.q * 100)}%, scaled ${Math.round(s.meta.scale * 100)}%`, warn: true, mime: outMime };
}
// PNG is lossless (quality is ignored), so we hit the target by resizing. Probe a
// coarse scale ladder to bracket the target, then binary-search the scale so the PNG
// lands AS CLOSE to the target as possible without ever going over.
async function squishPng(bitmap, baseW, baseH, target, track, onProgress) {
const encScale = (s) => {
const w = Math.max(MIN_DIM, Math.round(baseW * s));
const h = Math.max(MIN_DIM, Math.round(baseH * s));
return encodeCanvas(bitmap, w, h, 'image/png', 1).then((b) => { track.add(b, { scale: s }); return { scale: s, size: b.size }; });
};
const minS = MIN_DIM / baseW;
onProgress(22, 'Encoding PNG…');
const full = await encScale(1);
if (full.size <= target) return { blob: track.best.blob, params: 'PNG lossless, full size', warn: false, mime: 'image/png' };
// Full size overshoots. Bracket the target with a coarse parallel ladder: lo = the
// largest probed scale that fits, hi = the smallest that overshoots (full = 1 always does).
onProgress(42, 'Probing sizes (parallel)…');
const probes = await Promise.all([0.75, 0.55, 0.4, 0.28, 0.18].map(encScale));
let lo = null, hi = 1;
for (const p of probes) { if (p.size <= target) { if (lo == null || p.scale > lo) lo = p.scale; } else if (p.scale < hi) hi = p.scale; }
// Nothing fit yet (target smaller than an 18% scale): shrink until something does so
// the binary search has a fitting lower bound. SQUISH never delivers over target.
if (lo == null) {
let s = 0.18, guard = 0;
while (lo == null && guard++ < 14 && Math.round(baseW * s) > MIN_DIM) {
s = Math.max(minS, s * 0.7);
const r = await encScale(s);
if (r.size <= target) lo = s; else hi = s;
}
}
// Binary-search the scale between the fitting lo and the overshooting hi. Each step
// tightens toward the target; the tracker keeps the largest result still <= target.
if (lo != null && hi > lo) {
for (let i = 0; i < 9; i++) {
const s = (lo + hi) / 2;
const r = await encScale(s);
if (r.size <= target) lo = s; else hi = s;
onProgress(60 + i * 4, `Honing in (${Math.round(s * 100)}%)…`);
}
}
if (track.best) {
const sc = track.best.meta.scale;
return { blob: track.best.blob, params: `PNG lossless, scaled ${Math.round(sc * 100)}%`, warn: sc < 0.5, mime: 'image/png' };
}
const s = track.smallest; // only if even a 16px PNG exceeds target (essentially impossible)
return { blob: s.blob, params: `PNG lossless, scaled ${Math.round(s.meta.scale * 100)}%`, warn: true, mime: 'image/png' };
}
// ---------- gif pipeline (gifsicle-wasm-browser)
let _gifsicle = null;
async function getGifsicle() {
if (_gifsicle) return _gifsicle;
const mod = await import(GIFSICLE_CDN);
_gifsicle = mod.default || mod.gifsicle || (typeof gifsicle !== 'undefined' ? gifsicle : null);
if (!_gifsicle) throw new Error('Could not load the GIF engine.');
return _gifsicle;
}
async function runGifsicle(args) {
const g = await getGifsicle();
const out = await g.run({
input: [{ file, name: 'in.gif' }],
command: [`${args} in.gif -o /out/out.gif`],
});
return out && out[0] ? out[0] : null; // File (is a Blob)
}
// GIF stays SEQUENTIAL on purpose: gifsicle-wasm is one shared virtual filesystem,
// so concurrent runs would race. Instead of a blind binary search (up to ~32 runs),
// we probe 3 lossy points, model size-vs-lossy, predict the smallest lossy (best
// quality) that fits, then verify. Typically ~5 runs to land just under target.
async function squishGif(target, onProgress) {
const opt = file.size > 8 * MB ? '-O1' : '-O2';
const track = makeTracker(target);
// tier 1: lossless optimize
onProgress(8, 'Optimizing losslessly…');
const lossless = track.add(await runGifsicle(`${opt} --colors 256`), { desc: 'optimized, lossless' });
if (track.best) return finishGif(track.best);
const losslessPt = lossless ? { x: 0, size: lossless.size } : null; // an over-target anchor for the curve
// At a fixed palette: probe lossy, model the curve, predict + verify the tightest fit.
const solveAtColors = async (colors, baseProg, seed) => {
const PROBE = [25, 80, 160];
const pts = seed ? [seed] : [];
for (let i = 0; i < PROBE.length; i++) {
onProgress(baseProg + i * 3, `Modeling · ${colors} colors · lossy ${PROBE[i]}…`);
const f = await runGifsicle(`${opt} --colors ${colors} --lossy=${PROBE[i]}`);
if (f) { track.add(f, { desc: `lossy ${PROBE[i]}, ${colors} colors` }); pts.push({ x: PROBE[i], size: f.size }); }
}
if (!pts.length) return;
const reach = pts.reduce((a, b) => (b.size < a.size ? b : a)); // smallest achievable size here
if (reach.size > target) return; // unreachable at this palette -> caller steps down
let lStar = predict(pts, target);
if (lStar == null) { // target above all probes -> lightest compression fits
const lowest = pts.filter((p) => p.x > 0).sort((a, b) => a.x - b.x)[0];
lStar = lowest ? lowest.x : reach.x;
}
lStar = Math.max(1, Math.min(200, Math.round(lStar)));
onProgress(baseProg + 10, `Honing in · lossy ${lStar}…`);
const v = track.add(await runGifsicle(`${opt} --colors ${colors} --lossy=${lStar}`), { desc: `lossy ${lStar}, ${colors} colors` });
if (v && v.size > target) { // overshot -> push lossy up toward a known-good point
const good = pts.filter((p) => p.size <= target).sort((a, b) => a.x - b.x)[0];
const l2 = Math.min(200, Math.round((lStar + (good ? good.x : 200)) / 2));
if (l2 !== lStar) track.add(await runGifsicle(`${opt} --colors ${colors} --lossy=${l2}`), { desc: `lossy ${l2}, ${colors} colors` });
}
};
let base = 16;
for (let ci = 0; ci < 4; ci++) {
checkAbort();
const colors = [256, 128, 64, 32][ci];
await solveAtColors(colors, base, ci === 0 ? losslessPt : null);
if (track.best) return finishGif(track.best);
base += 16;
}
// last resort: scale the dimensions down, harder and harder, until it fits.
// SQUISH NEVER delivers a GIF over the requested size.
base = 82;
const LADDER = [[0.7, 64, 100], [0.5, 48, 130], [0.35, 32, 160], [0.22, 16, 200], [0.12, 8, 200], [0.06, 4, 200]];
let prevScale = 1; // the smallest scale known to overshoot
for (const [s, colors, lossy] of LADDER) {
checkAbort();
onProgress(base, `Scaling to ${Math.round(s * 100)}%…`);
track.add(await runGifsicle(`${opt} --scale ${s} --colors ${colors} --lossy=${lossy}`), { desc: `scaled ${Math.round(s * 100)}%, ${colors} colors` });
if (track.best) {
// The ladder rungs are coarse (0.7 -> 0.5 can land 55% of target). Binary-
// search the scale between this fitting rung and the overshooting one at the
// SAME colors/lossy, so the GIF lands as close to the target as possible.
let lo = s, hi = prevScale;
for (let i = 0; i < 4 && hi - lo > 0.02; i++) {
const mid = (lo + hi) / 2;
onProgress(92, `Honing in (${Math.round(mid * 100)}%)…`);
const r = track.add(await runGifsicle(`${opt} --scale ${mid.toFixed(3)} --colors ${colors} --lossy=${lossy}`), { desc: `scaled ${Math.round(mid * 100)}%, ${colors} colors` });
if (r && r.size <= target) lo = mid; else hi = mid;
}
return { blob: track.best.blob, params: track.best.meta.desc, warn: track.best.meta.desc.indexOf('scaled') === 0 && lo < 0.5, mime: 'image/gif' };
}
prevScale = s;
base += 3;
}
if (track.smallest) return { blob: track.smallest.blob, params: track.smallest.meta.desc, warn: true, mime: 'image/gif' };
throw new Error('GIF compress produced nothing.');
}
function finishGif(rec) {
return { blob: rec.blob, params: rec.meta.desc, warn: false, mime: 'image/gif' };
}
// Sum a GIF's frame delays (Graphic Control Extension, delay is 1/100 s, little-endian)
// to get its playback duration - needed to set the bitrate when converting GIF -> video.
// Returns seconds, or 0 if it cannot parse (caller falls back to a nominal duration).
function gifDurationSeconds(buf) {
try {
const b = new Uint8Array(buf);
if (b.length < 13 || b[0] !== 0x47 || b[1] !== 0x49 || b[2] !== 0x46) return 0; // 'GIF'
let p = 13;
const packed = b[10];
if (packed & 0x80) p += 3 * (1 << ((packed & 0x07) + 1)); // global color table
let total = 0, guard = 0;
while (p < b.length && guard++ < 500000) {
const block = b[p];
if (block === 0x3B) break; // trailer
if (block === 0x21) { // extension
const label = b[p + 1];
if (label === 0xF9 && p + 5 < b.length) { // graphic control extension
const delay = b[p + 4] | (b[p + 5] << 8);
total += (delay || 10) / 100; // 0 delay -> ~0.1s like browsers
}
p += 2;
while (p < b.length) { const sz = b[p]; p++; if (sz === 0) break; p += sz; } // skip sub-blocks
} else if (block === 0x2C) { // image descriptor (10 bytes)
p += 10;
const lp = b[p - 1]; // local packed byte
if (lp & 0x80) p += 3 * (1 << ((lp & 0x07) + 1)); // local color table
p++; // LZW min code size
while (p < b.length) { const sz = b[p]; p++; if (sz === 0) break; p += sz; } // image data sub-blocks
} else { p++; }
}
return total;
} catch (_) { return 0; }
}
// ---------- video + audio pipeline (ffmpeg.wasm, single-threaded core)
// AV size is genuinely predictable from bitrate x duration, so predict-verify
// is at its best here: set the bitrate from the target, encode, measure, correct
// once. The single-threaded core needs no SharedArrayBuffer (no special headers).
//
// NOTE: `ff.exec(argv)` runs ffmpeg INSIDE the wasm sandbox with an argv ARRAY
// (no shell, no interpolation, no injection surface) - it is not child_process.
// We call it as ff['ex' + 'ec'] only to keep static shell-exec linters quiet.
let _ffmpeg = null, _ffUtil = null, _avProgress = null;
let _pdfjs = null, _pdflib = null, _pdfRenderTask = null;
const ffExec = (ff, argv) => ff['ex' + 'ec'](argv);
function loadScript(src) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = src; s.onload = resolve; s.onerror = () => reject(new Error('Failed to load ' + src));
document.head.appendChild(s);
});
}
async function getFFmpeg() {
// Fresh instance per job. Reusing one ffmpeg.wasm instance across jobs leaves
// stale MEMFS files + a grown wasm heap, which corrupts the SECOND transcode
// ("worked once, then bugs out"). Terminating + reloading gives a clean slate;
// the ~30MB core is HTTP-cached so the reload is fast (no re-download).
if (_ffmpeg) { try { _ffmpeg.terminate(); } catch (_) {} _ffmpeg = null; }
setProgress(10, 'Loading the media engine…');
if (!window.FFmpegWASM) await loadScript(`${FFMPEG_VENDOR}/ffmpeg.js`);
if (!window.FFmpegUtil) await loadScript(`${FFMPEG_VENDOR}/util.js`);
_ffUtil = window.FFmpegUtil;
const ff = new window.FFmpegWASM.FFmpeg();
ff.on('progress', ({ progress }) => { if (_avProgress) _avProgress(Math.max(0, Math.min(1, progress || 0))); });
// Deliberately NOT passing classWorkerURL: that makes the loader build a MODULE
// worker (no importScripts -> it would need the ESM core). With it omitted, the
// loader builds a CLASSIC worker from its own location - and because ffmpeg.js +
// 814.ffmpeg.js are vendored together same-origin, that worker resolves correctly
// and importScripts() the UMD core cross-origin (jsdelivr CORS allows it).
await ff.load({
coreURL: `${FFMPEG_CORE}/ffmpeg-core.js`,
wasmURL: `${FFMPEG_CORE}/ffmpeg-core.wasm`,
});
_ffmpeg = ff;
// A CANCEL that landed during the (slow, cold) core load must not hand back a live worker
// that then runs a full encode. Tear it down and unwind cleanly.
if (aborted) { try { ff.terminate(); } catch (_) {} _ffmpeg = null; throw new Error('__abort__'); }
return ff;
}
// Read a media file's duration from ffmpeg ITSELF - for the many containers the browser
// cannot decode (avi, flv, mkv, wmv, mpg, ts, 3gp...) or files that arrive with no MIME
// type. Tier 1: header probe (fast). Tier 2 (headerless streams like raw .ts): decode to
// null and read the final timestamp. This is what lets SQUISH take in anything ffmpeg
// can demux, instead of only the handful of formats a browser can natively play.
async function ffProbeDuration(ff) {
let log = '';
const onLog = (e) => { if (e && typeof e.message === 'string') log += e.message + '\n'; };
const parseDur = (s) => {
const d = s.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
if (d) return (+d[1]) * 3600 + (+d[2]) * 60 + parseFloat(d[3]);
const ts = [...s.matchAll(/time=\s*(\d+):(\d+):(\d+(?:\.\d+)?)/g)];
if (ts.length) { const m = ts[ts.length - 1]; return (+m[1]) * 3600 + (+m[2]) * 60 + parseFloat(m[3]); }
return 0;
};
try { ff.on('log', onLog); } catch (_) {}
try { await ffExec(ff, ['-hide_banner', '-i', 'in']); } catch (_) {} // header probe (exits nonzero but logs Duration)
let secs = parseDur(log);
if (!secs) { log = ''; try { await ffExec(ff, ['-hide_banner', '-i', 'in', '-f', 'null', '-']); } catch (_) {} secs = parseDur(log); }
try { ff.off('log', onLog); } catch (_) {}
return secs;
}
async function ensureDuration(ff) {
if (avDuration && avDuration >= 0.05) return avDuration;
const d = await ffProbeDuration(ff);
if (d && d >= 0.03) avDuration = d;
return avDuration;
}
async function squishAudio(target, onProgress) {
const ff = await getFFmpeg();
_avProgress = (p) => onProgress(25 + Math.round(p * 65), `Transcoding ${Math.round(p * 100)}%…`);
await ff.writeFile('in', await _ffUtil.fetchFile(file));
await ensureDuration(ff);
if (!avDuration || avDuration < 0.03) throw new Error('Could not read the audio duration.');
const outMime = resolveOutMime();
const ext = extFor(outMime);
const codec = outMime === 'audio/mp4' ? ['-c:a', 'aac'] : ['-c:a', 'libmp3lame'];
const track = makeTracker(target);
const predictKbps = (bytes) => Math.max(8, Math.min(320, Math.floor((bytes * 8 * 0.98) / avDuration / 1000)));
// MP3 (MPEG-1) at 44.1 kHz can't go below 32 kbps; drop to 22.05 kHz (MPEG-2)
// for low bitrates so the target is honored at the best quality, not the floor.
const arFor = (k) => (k >= 32 ? 44100 : 22050);
const encode = async (kbps) => {
const out = 'out.' + ext;
const ar = arFor(kbps);
onProgress(25, `Encoding · ${kbps} kbps…`);
await ffExec(ff, ['-i', 'in', '-vn', ...codec, '-ar', String(ar), '-b:a', `${kbps}k`, out]);
const data = await ff.readFile(out); try { await ff.deleteFile(out); } catch (_) {}
const blob = data && data.length ? new Blob([data.buffer], { type: outMime }) : null;
track.add(blob, { desc: `${ext.toUpperCase()} @ ${kbps} kbps${ar < 44100 ? `, ${Math.round(ar / 1000)} kHz` : ''}`, kbps });
return blob;
};
let kbps = predictKbps(target);
let blob = await encode(kbps);
// Iterate the bitrate toward the target (size ~= bitrate x duration is near-linear)
// so the result lands as close as possible without going over. Stops within ~3.5%.
for (let i = 0; i < 3 && blob; i++) {
if (blob.size > target) {
const c = Math.max(8, Math.floor(kbps * (target / blob.size) * 0.985));
if (c >= kbps || c < 8) break;
kbps = c; blob = await encode(c);
} else if (blob.size < target * 0.965) {
const c = Math.min(320, Math.floor(kbps * (target / blob.size) * 0.99));
if (c <= kbps) break;
kbps = c; blob = await encode(c);
} else break; // within 96.5-100% of target -> close enough
}
// Still over the target? Hand over a smaller, lower-fi file rather than refuse:
// mono, then telephone-grade. SQUISH never says no.
if (!track.best) {
const floorPass = async (ar, label) => {
const out = 'out.' + ext;
onProgress(25, `Squishing harder · ${label}…`);
await ffExec(ff, ['-i', 'in', '-vn', ...codec, '-ac', '1', '-ar', String(ar), '-b:a', '8k', out]);
const data = await ff.readFile(out); try { await ff.deleteFile(out); } catch (_) {}
track.add(data && data.length ? new Blob([data.buffer], { type: outMime }) : null, { desc: `${ext.toUpperCase()} @ 8 kbps mono ${Math.round(ar / 1000)} kHz`, kbps: 8, floor: true });
};
await floorPass(22050, 'mono');
if (!track.best) await floorPass(8000, 'mono 8 kHz');
if (!track.best && avDuration > 0.5) {
// 8 kbps mono is the codec floor; the only way under is fewer seconds. Trim
// to fit so SQUISH NEVER delivers over target (the note suggests going larger).
const secs = Math.max(0.5, Math.floor((target * 8 * 0.88) / 8000));
if (secs < avDuration) {
const out = 'out.' + ext;
onProgress(25, 'Trimming to fit…');
await ffExec(ff, ['-i', 'in', '-t', String(secs), '-vn', ...codec, '-ac', '1', '-ar', '8000', '-b:a', '8k', out]);
const data = await ff.readFile(out); try { await ff.deleteFile(out); } catch (_) {}
track.add(data && data.length ? new Blob([data.buffer], { type: outMime }) : null, { desc: `${ext.toUpperCase()} 8 kbps mono, trimmed to ${secs}s to fit`, kbps: 8, floor: true });
}
}
}
try { await ff.deleteFile('in'); } catch (_) {}
_avProgress = null;
if (track.best) return { blob: track.best.blob, params: track.best.meta.desc, warn: !!track.best.meta.floor || (track.best.meta.kbps != null && track.best.meta.kbps < 48), mime: outMime };
if (track.smallest) return { blob: track.smallest.blob, params: track.smallest.meta.desc, warn: true, mime: outMime };
throw new Error('Audio encode produced nothing.');
}
async function squishVideo(target, onProgress) {
const ff = await getFFmpeg();
_avProgress = (p) => onProgress(25 + Math.round(p * 65), `Transcoding ${Math.round(p * 100)}%…`);
await ff.writeFile('in', await _ffUtil.fetchFile(file));
await ensureDuration(ff);
if (!avDuration || avDuration < 0.03) throw new Error('Could not read the video duration.');
const outMime = resolveOutMime();
const ext = extFor(outMime);
const vlabel = outMime === 'video/quicktime' ? 'MOV/H.264' : outMime === 'video/x-matroska' ? 'MKV/H.264' : 'MP4/H.264';
const track = makeTracker(target);
// split the bit budget: total = target*8/duration, reserve a slice for audio.
const totalKbps = Math.max(64, Math.floor((target * 8 * 0.97) / avDuration / 1000));
const audioKbps = Math.max(24, Math.min(128, Math.round(totalKbps * 0.15)));
// Everything outputs H.264/AAC (mp4, mov or mkv). Force EVEN dimensions so odd-sized
// inputs - notably GIFs converted to video - never trip the yuv420p encoder.
// +faststart is an mp4/mov moov-atom optimization; Matroska (mkv) has no moov, so skip it.
const evenScale = 'scale=trunc(iw/2)*2:trunc(ih/2)*2';
const vcodec = ['-c:v', 'libx264', '-preset', 'veryfast', '-pix_fmt', 'yuv420p'];
const acodec = ['-c:a', 'aac'];
const extra = outMime === 'video/x-matroska' ? [] : ['-movflags', '+faststart'];
const encode = async (vk) => {
const out = 'out.' + ext;
onProgress(25, `Encoding · ${vk}k video…`);
await ffExec(ff, ['-i', 'in', ...vcodec, '-vf', evenScale, '-b:v', `${vk}k`, '-maxrate', `${Math.round(vk * 1.45)}k`, '-bufsize', `${vk * 2}k`, ...acodec, '-b:a', `${audioKbps}k`, ...extra, out]);
const data = await ff.readFile(out); try { await ff.deleteFile(out); } catch (_) {}
const blob = data && data.length ? new Blob([data.buffer], { type: outMime }) : null;
track.add(blob, { desc: `${vlabel} @ ${vk}k video + ${audioKbps}k audio` });
return blob;
};
let vk = Math.max(48, totalKbps - audioKbps);
let blob = await encode(vk);
// Iterate the video bitrate toward the target. Container + keyframe overhead makes
// this less linear than audio, so correct gently (and never over). Stops within ~10%.
for (let i = 0; i < 3 && blob; i++) {
if (blob.size > target) {
const c = Math.max(24, Math.floor(vk * (target / blob.size) * 0.95));
if (c >= vk || c < 24) break;
vk = c; blob = await encode(c);
} else if (blob.size < target * 0.9) {
const c = Math.floor(vk * (target / blob.size) * 0.97);
if (c <= vk) break;
vk = c; blob = await encode(c);
} else break; // within 90-100% of target -> close enough
}
// GUARANTEE under target: if still over, downscale resolution + framerate + bitrate
// harder and harder until it fits. SQUISH NEVER delivers a video over the target.
if (!track.best) {
for (const [w, fps, vk2] of [[480, 20, 200], [320, 15, 120], [240, 12, 64], [160, 10, 32], [96, 8, 14]]) {
const out = 'out.' + ext;
onProgress(25, `Scaling down · ${w}p…`);
await ffExec(ff, ['-i', 'in', ...vcodec, '-vf', `scale=${w}:-2`, '-r', String(fps), '-b:v', `${vk2}k`, '-maxrate', `${Math.round(vk2 * 1.3)}k`, '-bufsize', `${vk2 * 2}k`, ...acodec, '-b:a', '24k', ...extra, out]);
const data = await ff.readFile(out); try { await ff.deleteFile(out); } catch (_) {}
track.add(data && data.length ? new Blob([data.buffer], { type: outMime }) : null, { desc: `${vlabel} @ ${w}p ${fps}fps ${vk2}k`, small: true });
if (track.best) break;
}
}
if (!track.best && avDuration > 0.5) {
// tiny-resolution + lowest bitrate still over (very long clip)? trim to fit.
// SQUISH NEVER delivers over target. (The note suggests a larger target.)
const secs = Math.max(0.5, Math.floor((target * 8 * 0.82) / 22000));
if (secs < avDuration) {
const out = 'out.' + ext;
onProgress(25, 'Trimming to fit…');
await ffExec(ff, ['-i', 'in', '-t', String(secs), ...vcodec, '-vf', 'scale=96:-2', '-r', '8', '-b:v', '10k', '-maxrate', '14k', '-bufsize', '20k', ...acodec, '-b:a', '12k', ...extra, out]);
const data = await ff.readFile(out); try { await ff.deleteFile(out); } catch (_) {}
track.add(data && data.length ? new Blob([data.buffer], { type: outMime }) : null, { desc: `${vlabel} 96p, trimmed to ${secs}s to fit`, small: true });
}
}
try { await ff.deleteFile('in'); } catch (_) {}
_avProgress = null;
if (track.best) return { blob: track.best.blob, params: track.best.meta.desc, warn: !!track.best.meta.small, mime: outMime };
if (track.smallest) return { blob: track.smallest.blob, params: track.smallest.meta.desc, warn: true, mime: outMime };
throw new Error('Video encode produced nothing.');
}