mevebu 1.10.0 → 2.0.0
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.
- package/README.md +17 -21
- package/mevebu/app/mevebu.js +1 -1
- package/mevebu/app/tailwind.css +110 -17
- package/mevebu/libs/js/core.js +1 -1
- package/mevebu/libs/js/csv.js +87 -0
- package/mevebu/libs/js/files.js +125 -0
- package/mevebu/libs/js/icons.js +2 -1
- package/mevebu/libs/js/pdf-engine.js +67 -0
- package/mevebu/libs/js/registry.js +253 -37
- package/mevebu/libs/js/tools/audio-converter.js +228 -0
- package/mevebu/libs/js/tools/csv-viewer.js +1 -51
- package/mevebu/libs/js/tools/data-dictionary.js +204 -0
- package/mevebu/libs/js/tools/image-to-pdf.js +251 -0
- package/mevebu/libs/js/tools/pdf-merge.js +158 -0
- package/mevebu/libs/js/tools/pdf-organizer.js +215 -0
- package/mevebu/libs/js/tools/pdf-split.js +198 -0
- package/mevebu/libs/js/tools/pdf-to-image.js +190 -0
- package/mevebu/libs/js/tools/pdf-to-text.js +172 -0
- package/mevebu/libs/js/tools/roster-generator.js +195 -0
- package/mevebu/libs/js/tools/sample-csv.js +252 -0
- package/mevebu/libs/js/tools/sql-insert.js +184 -0
- package/mevebu/libs/js/tools/svg-to-png.js +207 -0
- package/mevebu/libs/js/tools/team-generator.js +203 -0
- package/mevebu/libs/js/tools/text-to-pdf.js +182 -0
- package/mevebu/libs/js/tools/video-converter.js +316 -0
- package/package.json +1 -1
- package/mevebu/libs/js/tools/coin-flip.js +0 -78
- package/mevebu/libs/js/tools/name-picker.js +0 -68
- package/mevebu/libs/js/tools/spin-wheel.js +0 -168
package/mevebu/libs/js/icons.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// GENERATED FILE, DO NOT EDIT.
|
|
2
|
-
// Source: lucide v1.57.0 (ISC), icons/
|
|
2
|
+
// Source: lucide v1.57.0 (ISC), icons/175 names from scripts/vendor-icons.mjs
|
|
3
3
|
// Regenerate with: npm run icons
|
|
4
4
|
|
|
5
5
|
const D = {
|
|
@@ -45,6 +45,7 @@ const D = {
|
|
|
45
45
|
"download": [ ["path", { d: "M12 15V3" }], ["path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }], ["path", { d: "m7 10 5 5 5-5" }] ],
|
|
46
46
|
"upload": [ ["path", { d: "M12 3v12" }], ["path", { d: "m17 8-5-5-5 5" }], ["path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }] ],
|
|
47
47
|
"delete": [ [ "path", { d: "M10 5a2 2 0 0 0-1.344.519l-6.328 5.74a1 1 0 0 0 0 1.481l6.328 5.741A2 2 0 0 0 10 19h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2z" } ], ["path", { d: "m12 9 6 6" }], ["path", { d: "m18 9-6 6" }] ],
|
|
48
|
+
"scissors": [ ["circle", { cx: "6", cy: "6", r: "3" }], ["path", { d: "M8.12 8.12 12 12" }], ["path", { d: "M20 4 8.12 15.88" }], ["circle", { cx: "6", cy: "18", r: "3" }], ["path", { d: "M14.8 14.8 20 20" }] ],
|
|
48
49
|
"arrow-left-right": [ ["path", { d: "M8 3 4 7l4 4" }], ["path", { d: "M4 7h16" }], ["path", { d: "m16 21 4-4-4-4" }], ["path", { d: "M20 17H4" }] ],
|
|
49
50
|
"trash": [ ["path", { d: "M10 11v6" }], ["path", { d: "M14 11v6" }], ["path", { d: "M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" }], ["path", { d: "M3 6h18" }], ["path", { d: "M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" }] ],
|
|
50
51
|
"plus": [ ["path", { d: "M5 12h14" }], ["path", { d: "M12 5v14" }] ],
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Loaders for the two vendored PDF libraries. Each one is fetched the first time
|
|
2
|
+
// a tool needs it, so opening the index never downloads a PDF engine.
|
|
3
|
+
const PDFJS_URL = new URL("./vendor/pdf.min.mjs", import.meta.url).href;
|
|
4
|
+
const WORKER_URL = new URL("./vendor/pdf.worker.min.mjs", import.meta.url).href;
|
|
5
|
+
const PDFLIB_URL = new URL("./vendor/pdf-lib.min.js", import.meta.url).href;
|
|
6
|
+
|
|
7
|
+
let pdfjsPromise = null;
|
|
8
|
+
let pdfLibPromise = null;
|
|
9
|
+
|
|
10
|
+
/** pdf.js, configured to run its worker from this folder, never from a CDN. */
|
|
11
|
+
export function loadPdfjs() {
|
|
12
|
+
if (!pdfjsPromise) {
|
|
13
|
+
pdfjsPromise = import(PDFJS_URL).then((module) => {
|
|
14
|
+
module.GlobalWorkerOptions.workerSrc = WORKER_URL;
|
|
15
|
+
return module;
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return pdfjsPromise;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** pdf-lib, which ships as a UMD bundle and lands on `globalThis.PDFLib`. */
|
|
22
|
+
export function loadPdfLib() {
|
|
23
|
+
if (globalThis.PDFLib) return Promise.resolve(globalThis.PDFLib);
|
|
24
|
+
if (!pdfLibPromise) {
|
|
25
|
+
pdfLibPromise = new Promise((resolve, reject) => {
|
|
26
|
+
const script = document.createElement("script");
|
|
27
|
+
script.src = PDFLIB_URL;
|
|
28
|
+
script.onload = () =>
|
|
29
|
+
globalThis.PDFLib ? resolve(globalThis.PDFLib) : reject(new Error("The PDF writer did not start."));
|
|
30
|
+
script.onerror = () => reject(new Error("The PDF writer could not be loaded."));
|
|
31
|
+
document.head.append(script);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return pdfLibPromise;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function readPdfjs(file) {
|
|
38
|
+
const pdfjs = await loadPdfjs();
|
|
39
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
40
|
+
return pdfjs.getDocument({ data: bytes, isEvalSupported: false }).promise;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function readPdfLib(file) {
|
|
44
|
+
const { PDFDocument } = await loadPdfLib();
|
|
45
|
+
return PDFDocument.load(await file.arrayBuffer(), { ignoreEncryption: false });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** "1-3, 7, 10-" against a page count, returning zero based indexes. */
|
|
49
|
+
export function parsePageRange(text, count) {
|
|
50
|
+
const clean = String(text || "").trim();
|
|
51
|
+
if (!clean) return Array.from({ length: count }, (_, i) => i);
|
|
52
|
+
const pages = new Set();
|
|
53
|
+
for (const part of clean.split(",")) {
|
|
54
|
+
const piece = part.trim();
|
|
55
|
+
if (!piece) continue;
|
|
56
|
+
const match = /^(\d+)?\s*(?:-\s*(\d+)?)?$/.exec(piece);
|
|
57
|
+
if (!match) throw new Error(`"${piece}" is not a page or a range.`);
|
|
58
|
+
const from = match[1] ? Number(match[1]) : 1;
|
|
59
|
+
const to = match[2] ? Number(match[2]) : match[1] ? from : count;
|
|
60
|
+
if (from < 1 || to < 1) throw new Error("Pages start at 1.");
|
|
61
|
+
if (from > count || to > count) throw new Error(`This file has ${count} page${count === 1 ? "" : "s"}.`);
|
|
62
|
+
if (from > to) throw new Error(`"${piece}" runs backwards.`);
|
|
63
|
+
for (let page = from; page <= to; page++) pages.add(page - 1);
|
|
64
|
+
}
|
|
65
|
+
if (!pages.size) throw new Error("That range selects no pages.");
|
|
66
|
+
return [...pages].sort((a, b) => a - b);
|
|
67
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// one object here plus one file in ./tools/.
|
|
5
5
|
|
|
6
6
|
export const CATEGORIES = [
|
|
7
|
-
{ id: "
|
|
7
|
+
{ id: "data-gen", name: "Data Generator", icon: "database" },
|
|
8
8
|
{ id: "security", name: "Passwords and keys", icon: "shield-check" },
|
|
9
9
|
{ id: "developer", name: "Developer", icon: "terminal" },
|
|
10
10
|
{ id: "convert", name: "Convert", icon: "repeat" },
|
|
@@ -31,58 +31,94 @@ export const CATEGORIES = [
|
|
|
31
31
|
// one-line intro plus a short list of steps. Keep steps concrete.
|
|
32
32
|
export const TOOLS = [
|
|
33
33
|
{
|
|
34
|
-
id: "
|
|
35
|
-
category: "
|
|
36
|
-
name: "
|
|
37
|
-
icon: "
|
|
38
|
-
note: "
|
|
39
|
-
about: "
|
|
34
|
+
id: "team-generator",
|
|
35
|
+
category: "data-gen",
|
|
36
|
+
name: "Team Generator",
|
|
37
|
+
icon: "users-round",
|
|
38
|
+
note: "Split a list into balanced groups.",
|
|
39
|
+
about: "Two modes, exclusion pairs, and one lead per group.",
|
|
40
40
|
tutorial: {
|
|
41
|
-
intro: "
|
|
41
|
+
intro: "Turn a list of names into balanced groups.",
|
|
42
42
|
steps: [
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
43
|
+
"Paste the names, one per line.",
|
|
44
|
+
"Pick the number of groups or the size per group.",
|
|
45
|
+
"Add exclusion pairs and leads if you need them, then press Generate.",
|
|
46
46
|
],
|
|
47
47
|
},
|
|
48
|
-
keywords: "random
|
|
49
|
-
load: () => import("./tools/
|
|
48
|
+
keywords: "team generator group splitter random groups classroom workshop",
|
|
49
|
+
load: () => import("./tools/team-generator.js"),
|
|
50
50
|
},
|
|
51
51
|
{
|
|
52
|
-
id: "
|
|
53
|
-
category: "
|
|
54
|
-
name: "
|
|
55
|
-
icon: "
|
|
56
|
-
note: "
|
|
57
|
-
about: "
|
|
52
|
+
id: "roster-generator",
|
|
53
|
+
category: "data-gen",
|
|
54
|
+
name: "Roster Rotation",
|
|
55
|
+
icon: "calendar-check",
|
|
56
|
+
note: "Build a rotating duty roster.",
|
|
57
|
+
about: "Names take turns across dates, with pins and weekend skips.",
|
|
58
58
|
tutorial: {
|
|
59
|
-
intro: "
|
|
59
|
+
intro: "Build a duty roster that rotates fairly.",
|
|
60
60
|
steps: [
|
|
61
|
-
"Paste
|
|
62
|
-
"Set how many
|
|
63
|
-
"
|
|
61
|
+
"Paste the names in the order they should take turns.",
|
|
62
|
+
"Set the date range, how many people per day, and whether to skip weekends.",
|
|
63
|
+
"Pin a date if someone must take it, then copy or download the roster.",
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
keywords: "roster rotation duty schedule shift piket jadwal",
|
|
67
|
+
load: () => import("./tools/roster-generator.js"),
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: "sample-csv",
|
|
71
|
+
category: "data-gen",
|
|
72
|
+
name: "Sample CSV",
|
|
73
|
+
icon: "table",
|
|
74
|
+
note: "Generate a CSV with the columns you define.",
|
|
75
|
+
about: "Pick a type per column, a row count and a seed that reproduces the file.",
|
|
76
|
+
tutorial: {
|
|
77
|
+
intro: "Make a CSV that fits a shape you describe.",
|
|
78
|
+
steps: [
|
|
79
|
+
"Add a column and pick its type.",
|
|
80
|
+
"Set the row count and a seed. The same seed always makes the same file.",
|
|
81
|
+
"Press Download CSV, or copy from the preview.",
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
keywords: "sample csv fake table fixture test data generator seed",
|
|
85
|
+
load: () => import("./tools/sample-csv.js"),
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "sql-insert",
|
|
89
|
+
category: "data-gen",
|
|
90
|
+
name: "SQL Insert",
|
|
91
|
+
icon: "database",
|
|
92
|
+
note: "Turn a CSV into INSERT statements.",
|
|
93
|
+
about: "Postgres, MySQL and SQLite, with correct quoting and NULL handling.",
|
|
94
|
+
tutorial: {
|
|
95
|
+
intro: "Seed a database from a spreadsheet.",
|
|
96
|
+
steps: [
|
|
97
|
+
"Drop a CSV with a header row.",
|
|
98
|
+
"Set the table, the schema and the dialect.",
|
|
99
|
+
"Copy the SQL or download a .sql file.",
|
|
64
100
|
],
|
|
65
101
|
},
|
|
66
|
-
keywords: "
|
|
67
|
-
load: () => import("./tools/
|
|
102
|
+
keywords: "sql insert generator csv to sql seed database postgres mysql sqlite",
|
|
103
|
+
load: () => import("./tools/sql-insert.js"),
|
|
68
104
|
},
|
|
69
105
|
{
|
|
70
|
-
id: "
|
|
71
|
-
category: "
|
|
72
|
-
name: "
|
|
73
|
-
icon: "
|
|
74
|
-
note: "
|
|
75
|
-
about: "
|
|
106
|
+
id: "data-dictionary",
|
|
107
|
+
category: "data-gen",
|
|
108
|
+
name: "Data Dictionary",
|
|
109
|
+
icon: "table-2",
|
|
110
|
+
note: "Describe a CSV column by column.",
|
|
111
|
+
about: "Type, nulls, distinct values, range and a sample, as a table or Markdown.",
|
|
76
112
|
tutorial: {
|
|
77
|
-
intro: "
|
|
113
|
+
intro: "Write the document a dataset needs.",
|
|
78
114
|
steps: [
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
"
|
|
115
|
+
"Drop a CSV.",
|
|
116
|
+
"Read the table: type, empty cells, distinct count, min, max, sample.",
|
|
117
|
+
"Copy the Markdown or download the CSV version.",
|
|
82
118
|
],
|
|
83
119
|
},
|
|
84
|
-
keywords: "
|
|
85
|
-
load: () => import("./tools/
|
|
120
|
+
keywords: "data dictionary csv schema profile columns nulls distinct metadata",
|
|
121
|
+
load: () => import("./tools/data-dictionary.js"),
|
|
86
122
|
},
|
|
87
123
|
|
|
88
124
|
{
|
|
@@ -395,6 +431,186 @@ export const TOOLS = [
|
|
|
395
431
|
keywords: "favicon ico png apple touch icon android manifest icon generator",
|
|
396
432
|
load: () => import("./tools/favicon-converter.js"),
|
|
397
433
|
},
|
|
434
|
+
{
|
|
435
|
+
id: "image-to-pdf",
|
|
436
|
+
category: "convert",
|
|
437
|
+
name: "Image to PDF",
|
|
438
|
+
icon: "image-up",
|
|
439
|
+
note: "Turn images into one PDF.",
|
|
440
|
+
about: "Pick a page size, drag the images into order, and get one PDF.",
|
|
441
|
+
tutorial: {
|
|
442
|
+
intro: "Turn one image or a set into a single PDF.",
|
|
443
|
+
steps: [
|
|
444
|
+
"Drop the images and drag them into the order you want.",
|
|
445
|
+
"Pick A4 or Letter, a margin, and whether each image fits inside the page or fills it.",
|
|
446
|
+
"Press Build PDF. The file downloads from this tab.",
|
|
447
|
+
],
|
|
448
|
+
},
|
|
449
|
+
keywords: "image to pdf jpg to pdf png to pdf photo to pdf combine images document",
|
|
450
|
+
load: () => import("./tools/image-to-pdf.js"),
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
id: "pdf-to-image",
|
|
454
|
+
category: "convert",
|
|
455
|
+
name: "PDF to Image",
|
|
456
|
+
icon: "file-text",
|
|
457
|
+
note: "Render every page as a PNG or JPEG.",
|
|
458
|
+
about: "Choose a page range and a scale, then take the pages as a ZIP of images.",
|
|
459
|
+
tutorial: {
|
|
460
|
+
intro: "Every page of a PDF as its own image.",
|
|
461
|
+
steps: [
|
|
462
|
+
"Drop a PDF. A preview of the first pages appears.",
|
|
463
|
+
"Set the range, the format, and the scale. 2x is a sharp screen size.",
|
|
464
|
+
"Press Download ZIP for one image per page.",
|
|
465
|
+
],
|
|
466
|
+
},
|
|
467
|
+
keywords: "pdf to image pdf to png pdf to jpg render page screenshot",
|
|
468
|
+
load: () => import("./tools/pdf-to-image.js"),
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
id: "pdf-merge",
|
|
472
|
+
category: "convert",
|
|
473
|
+
name: "PDF Merge",
|
|
474
|
+
icon: "combine",
|
|
475
|
+
note: "Join several PDFs into one.",
|
|
476
|
+
about: "Files merge from top to bottom, and the pages stay as they were.",
|
|
477
|
+
tutorial: {
|
|
478
|
+
intro: "Combine PDFs without re-rendering them.",
|
|
479
|
+
steps: [
|
|
480
|
+
"Drop two or more PDFs.",
|
|
481
|
+
"Drag or use the arrows to set the order.",
|
|
482
|
+
"Press Merge PDFs. Text stays selectable because the pages are copied, not redrawn.",
|
|
483
|
+
],
|
|
484
|
+
},
|
|
485
|
+
keywords: "merge pdf combine pdf join pdf append document",
|
|
486
|
+
load: () => import("./tools/pdf-merge.js"),
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
id: "pdf-split",
|
|
490
|
+
category: "convert",
|
|
491
|
+
name: "PDF Split",
|
|
492
|
+
icon: "scissors",
|
|
493
|
+
note: "Cut one PDF into parts.",
|
|
494
|
+
about: "Split by range, by a fixed page count, or one file per page.",
|
|
495
|
+
tutorial: {
|
|
496
|
+
intro: "Turn one PDF into several files.",
|
|
497
|
+
steps: [
|
|
498
|
+
"Drop a PDF.",
|
|
499
|
+
"Pick By range, Every N pages, or One per page.",
|
|
500
|
+
"Press Split and download for a ZIP of the parts.",
|
|
501
|
+
],
|
|
502
|
+
},
|
|
503
|
+
keywords: "split pdf cut pdf separate pages extract range",
|
|
504
|
+
load: () => import("./tools/pdf-split.js"),
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
id: "pdf-to-text",
|
|
508
|
+
category: "convert",
|
|
509
|
+
name: "PDF to Text",
|
|
510
|
+
icon: "text-search",
|
|
511
|
+
note: "Copy the text out of a PDF.",
|
|
512
|
+
about: "Each page is labelled, so the order survives the copy.",
|
|
513
|
+
tutorial: {
|
|
514
|
+
intro: "Read the text layer of a PDF.",
|
|
515
|
+
steps: [
|
|
516
|
+
"Drop a PDF.",
|
|
517
|
+
"Set a page range, or leave it empty for every page.",
|
|
518
|
+
"Press Extract text, then copy it or download a .txt. A scan has no text layer, and the tool says so.",
|
|
519
|
+
],
|
|
520
|
+
},
|
|
521
|
+
keywords: "pdf to text extract text copy pdf txt document",
|
|
522
|
+
load: () => import("./tools/pdf-to-text.js"),
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
id: "pdf-organizer",
|
|
526
|
+
category: "convert",
|
|
527
|
+
name: "PDF Page Organizer",
|
|
528
|
+
icon: "layout-grid",
|
|
529
|
+
note: "Reorder, rotate and delete pages.",
|
|
530
|
+
about: "Every page is a thumbnail. Drag it, turn it, or drop it.",
|
|
531
|
+
tutorial: {
|
|
532
|
+
intro: "Rearrange a PDF page by page.",
|
|
533
|
+
steps: [
|
|
534
|
+
"Drop a PDF and wait for the thumbnails.",
|
|
535
|
+
"Drag a page to move it, use the arrows, rotate it, or delete it.",
|
|
536
|
+
"Press Save PDF. Your original file is never touched.",
|
|
537
|
+
],
|
|
538
|
+
},
|
|
539
|
+
keywords: "reorder pdf rotate pdf delete page organize pages arrange",
|
|
540
|
+
load: () => import("./tools/pdf-organizer.js"),
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
id: "text-to-pdf",
|
|
544
|
+
category: "convert",
|
|
545
|
+
name: "Text to PDF",
|
|
546
|
+
icon: "file-text",
|
|
547
|
+
note: "Turn plain text into a clean PDF.",
|
|
548
|
+
about: "Wrapped lines, real page breaks, selectable text.",
|
|
549
|
+
tutorial: {
|
|
550
|
+
intro: "Paste text and get a PDF.",
|
|
551
|
+
steps: [
|
|
552
|
+
"Paste the text and add an optional title.",
|
|
553
|
+
"Pick a page size, margin, font size and line height.",
|
|
554
|
+
"Press Build PDF. Long lines wrap and long text breaks onto new pages.",
|
|
555
|
+
],
|
|
556
|
+
},
|
|
557
|
+
keywords: "text to pdf txt to pdf write document page break",
|
|
558
|
+
load: () => import("./tools/text-to-pdf.js"),
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
id: "svg-to-png",
|
|
562
|
+
category: "convert",
|
|
563
|
+
name: "SVG to PNG",
|
|
564
|
+
icon: "image-up",
|
|
565
|
+
note: "Render an SVG at any pixel size.",
|
|
566
|
+
about: "Drawn at the size you ask for, so the PNG stays sharp.",
|
|
567
|
+
tutorial: {
|
|
568
|
+
intro: "Turn a vector into a PNG.",
|
|
569
|
+
steps: [
|
|
570
|
+
"Drop an SVG.",
|
|
571
|
+
"Set the width and height, or pick a preset.",
|
|
572
|
+
"Choose a transparent or solid background, then download the PNG.",
|
|
573
|
+
],
|
|
574
|
+
},
|
|
575
|
+
keywords: "svg to png vector rasterize icon export",
|
|
576
|
+
load: () => import("./tools/svg-to-png.js"),
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
id: "audio-converter",
|
|
580
|
+
category: "convert",
|
|
581
|
+
name: "Audio Converter",
|
|
582
|
+
icon: "activity",
|
|
583
|
+
note: "Convert audio to WAV.",
|
|
584
|
+
about: "Resample, trim and normalize, then write 16 bit PCM.",
|
|
585
|
+
tutorial: {
|
|
586
|
+
intro: "Decode an audio file and write a clean WAV.",
|
|
587
|
+
steps: [
|
|
588
|
+
"Drop an audio file the browser can decode.",
|
|
589
|
+
"Pick a sample rate and mono or stereo, then trim if you need to.",
|
|
590
|
+
"Press Convert to WAV.",
|
|
591
|
+
],
|
|
592
|
+
},
|
|
593
|
+
keywords: "audio converter wav mp3 ogg flac resample normalize trim",
|
|
594
|
+
load: () => import("./tools/audio-converter.js"),
|
|
595
|
+
},
|
|
596
|
+
{
|
|
597
|
+
id: "video-converter",
|
|
598
|
+
category: "convert",
|
|
599
|
+
name: "Video Converter",
|
|
600
|
+
icon: "video",
|
|
601
|
+
note: "Convert a video to WebM.",
|
|
602
|
+
about: "Or pull the audio track out as Opus.",
|
|
603
|
+
tutorial: {
|
|
604
|
+
intro: "Record a video into WebM, or take its audio.",
|
|
605
|
+
steps: [
|
|
606
|
+
"Drop a video the browser can play.",
|
|
607
|
+
"Pick WebM video or audio only, a resolution and a bitrate.",
|
|
608
|
+
"Press Convert and leave the tab in front. Recording runs at real time.",
|
|
609
|
+
],
|
|
610
|
+
},
|
|
611
|
+
keywords: "video converter webm mp4 opus audio track extract transcode",
|
|
612
|
+
load: () => import("./tools/video-converter.js"),
|
|
613
|
+
},
|
|
398
614
|
|
|
399
615
|
{
|
|
400
616
|
id: "lorem-ipsum",
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Audio to WAV. The file is decoded with the Web Audio API, optionally
|
|
2
|
+
// resampled, normalized and trimmed, then written as 16 bit PCM by hand.
|
|
3
|
+
import {
|
|
4
|
+
el, toast, download, bench, stack, panel, selectInput, numberInput, checkbox,
|
|
5
|
+
button, actions, toolcard, blank, notice,
|
|
6
|
+
} from "../core.js";
|
|
7
|
+
import { dropzone, baseName, formatBytes } from "../files.js";
|
|
8
|
+
|
|
9
|
+
const RATES = [
|
|
10
|
+
{ value: "22050", label: "22.05 kHz" },
|
|
11
|
+
{ value: "44100", label: "44.1 kHz" },
|
|
12
|
+
{ value: "48000", label: "48 kHz" },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function encodeWav(channels, sampleRate) {
|
|
16
|
+
const length = channels[0].length;
|
|
17
|
+
const count = channels.length;
|
|
18
|
+
const dataBytes = length * count * 2;
|
|
19
|
+
const buffer = new ArrayBuffer(44 + dataBytes);
|
|
20
|
+
const view = new DataView(buffer);
|
|
21
|
+
const bytes = new Uint8Array(buffer);
|
|
22
|
+
const ascii = (offset, text) => {
|
|
23
|
+
for (let i = 0; i < text.length; i++) bytes[offset + i] = text.charCodeAt(i);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
ascii(0, "RIFF");
|
|
27
|
+
view.setUint32(4, 36 + dataBytes, true);
|
|
28
|
+
ascii(8, "WAVE");
|
|
29
|
+
ascii(12, "fmt ");
|
|
30
|
+
view.setUint32(16, 16, true);
|
|
31
|
+
view.setUint16(20, 1, true);
|
|
32
|
+
view.setUint16(22, count, true);
|
|
33
|
+
view.setUint32(24, sampleRate, true);
|
|
34
|
+
view.setUint32(28, sampleRate * count * 2, true);
|
|
35
|
+
view.setUint16(32, count * 2, true);
|
|
36
|
+
view.setUint16(34, 16, true);
|
|
37
|
+
ascii(36, "data");
|
|
38
|
+
view.setUint32(40, dataBytes, true);
|
|
39
|
+
|
|
40
|
+
let at = 44;
|
|
41
|
+
for (let i = 0; i < length; i++) {
|
|
42
|
+
for (let channel = 0; channel < count; channel++) {
|
|
43
|
+
const sample = Math.max(-1, Math.min(1, channels[channel][i]));
|
|
44
|
+
view.setInt16(at, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
|
|
45
|
+
at += 2;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return new Blob([buffer], { type: "audio/wav" });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function mount(slot) {
|
|
52
|
+
let file = null;
|
|
53
|
+
let buffer = null;
|
|
54
|
+
let busy = false;
|
|
55
|
+
let token = 0;
|
|
56
|
+
|
|
57
|
+
const rate = selectInput(RATES, { value: "44100", "aria-label": "Sample rate" });
|
|
58
|
+
const channels = selectInput(
|
|
59
|
+
[
|
|
60
|
+
{ value: "keep", label: "Keep" },
|
|
61
|
+
{ value: "1", label: "Mono" },
|
|
62
|
+
{ value: "2", label: "Stereo" },
|
|
63
|
+
],
|
|
64
|
+
{ value: "keep", "aria-label": "Channels" }
|
|
65
|
+
);
|
|
66
|
+
const start = numberInput({ min: "0", step: "0.1", value: "0", "aria-label": "Start seconds" });
|
|
67
|
+
const end = numberInput({ min: "0", step: "0.1", value: "0", "aria-label": "End seconds" });
|
|
68
|
+
const normalize = checkbox("Normalize volume", { checked: false });
|
|
69
|
+
|
|
70
|
+
const error = notice("warn");
|
|
71
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
72
|
+
const wave = el("div", { class: "grid h-24 items-end gap-px rounded-r1 border border-orbit bg-deck-2 p-2 [grid-template-columns:repeat(96,minmax(0,1fr))]" });
|
|
73
|
+
const empty = blank("activity", "Open an audio file to convert it.");
|
|
74
|
+
|
|
75
|
+
const drop = dropzone({ label: "Drop audio here", accept: "audio/*", iconName: "activity" });
|
|
76
|
+
const clearButton = button("Clear", { icon: "trash", kind: "quiet" });
|
|
77
|
+
const convertButton = button("Convert to WAV", { icon: "download", kind: "fill" });
|
|
78
|
+
|
|
79
|
+
function reset() {
|
|
80
|
+
token++;
|
|
81
|
+
file = null;
|
|
82
|
+
buffer = null;
|
|
83
|
+
wave.replaceChildren();
|
|
84
|
+
wave.hidden = true;
|
|
85
|
+
summary.textContent = "";
|
|
86
|
+
error.write("");
|
|
87
|
+
empty.hidden = false;
|
|
88
|
+
convertButton.disabled = true;
|
|
89
|
+
clearButton.disabled = true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function drawWave() {
|
|
93
|
+
const data = buffer.getChannelData(0);
|
|
94
|
+
const bars = 96;
|
|
95
|
+
const step = Math.max(1, Math.floor(data.length / bars));
|
|
96
|
+
wave.replaceChildren(
|
|
97
|
+
...Array.from({ length: bars }, (_, index) => {
|
|
98
|
+
let peak = 0;
|
|
99
|
+
const from = index * step;
|
|
100
|
+
for (let i = from; i < Math.min(from + step, data.length); i += 16) {
|
|
101
|
+
const value = Math.abs(data[i]);
|
|
102
|
+
if (value > peak) peak = value;
|
|
103
|
+
}
|
|
104
|
+
return el("span", {
|
|
105
|
+
class: "block w-full rounded-[1px] bg-jade/70",
|
|
106
|
+
style: `height:${Math.max(2, Math.round(peak * 100))}%`,
|
|
107
|
+
});
|
|
108
|
+
})
|
|
109
|
+
);
|
|
110
|
+
wave.hidden = false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function open(files) {
|
|
114
|
+
const picked = Array.from(files).find((entry) => entry.type.startsWith("audio/") || /\.(wav|mp3|ogg|flac|m4a|aac|opus|webm)$/i.test(entry.name));
|
|
115
|
+
if (!picked) {
|
|
116
|
+
error.write("That file is not audio.");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
reset();
|
|
120
|
+
const current = ++token;
|
|
121
|
+
summary.textContent = "Decoding";
|
|
122
|
+
try {
|
|
123
|
+
const context = new AudioContext();
|
|
124
|
+
const decoded = await context.decodeAudioData(await picked.arrayBuffer());
|
|
125
|
+
await context.close();
|
|
126
|
+
if (current !== token) return;
|
|
127
|
+
file = picked;
|
|
128
|
+
buffer = decoded;
|
|
129
|
+
end.value = decoded.duration.toFixed(2);
|
|
130
|
+
summary.textContent = `${picked.name} · ${decoded.duration.toFixed(2)} s · ${decoded.sampleRate} Hz · ${decoded.numberOfChannels} ch · ${formatBytes(picked.size)}`;
|
|
131
|
+
empty.hidden = true;
|
|
132
|
+
convertButton.disabled = false;
|
|
133
|
+
clearButton.disabled = false;
|
|
134
|
+
error.write("");
|
|
135
|
+
drawWave();
|
|
136
|
+
} catch (caught) {
|
|
137
|
+
if (current !== token) return;
|
|
138
|
+
summary.textContent = "";
|
|
139
|
+
error.write(caught?.message || "This audio file could not be decoded.");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function convert() {
|
|
144
|
+
if (!buffer || busy) return;
|
|
145
|
+
busy = true;
|
|
146
|
+
const current = ++token;
|
|
147
|
+
convertButton.disabled = true;
|
|
148
|
+
summary.textContent = "Rendering";
|
|
149
|
+
error.write("");
|
|
150
|
+
try {
|
|
151
|
+
const targetRate = Number(rate.value) || 44100;
|
|
152
|
+
const from = Math.max(0, Math.min(buffer.duration, Number(start.value) || 0));
|
|
153
|
+
const to = Math.max(from + 0.01, Math.min(buffer.duration, Number(end.value) || buffer.duration));
|
|
154
|
+
const seconds = to - from;
|
|
155
|
+
const targetChannels = channels.value === "keep" ? buffer.numberOfChannels : Number(channels.value);
|
|
156
|
+
|
|
157
|
+
const offline = new OfflineAudioContext(targetChannels, Math.ceil(seconds * targetRate), targetRate);
|
|
158
|
+
const source = offline.createBufferSource();
|
|
159
|
+
source.buffer = buffer;
|
|
160
|
+
source.connect(offline.destination);
|
|
161
|
+
source.start(0, from, seconds);
|
|
162
|
+
const rendered = await offline.startRendering();
|
|
163
|
+
if (current !== token) return;
|
|
164
|
+
|
|
165
|
+
const out = [];
|
|
166
|
+
for (let channel = 0; channel < targetChannels; channel++) {
|
|
167
|
+
const fromChannel = rendered.numberOfChannels === 1 ? 0 : Math.min(channel, rendered.numberOfChannels - 1);
|
|
168
|
+
out.push(rendered.getChannelData(fromChannel).slice());
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (normalize.input.checked) {
|
|
172
|
+
let peak = 0;
|
|
173
|
+
for (const channel of out) for (let i = 0; i < channel.length; i++) peak = Math.max(peak, Math.abs(channel[i]));
|
|
174
|
+
if (peak > 0) {
|
|
175
|
+
const gain = 0.98 / peak;
|
|
176
|
+
for (const channel of out) for (let i = 0; i < channel.length; i++) channel[i] *= gain;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const blob = encodeWav(out, targetRate);
|
|
181
|
+
download(`${baseName(file.name)}.wav`, blob);
|
|
182
|
+
summary.textContent = `${seconds.toFixed(2)} s · ${targetRate} Hz · ${targetChannels === 1 ? "mono" : "stereo"} · ${formatBytes(blob.size)}`;
|
|
183
|
+
toast("WAV ready");
|
|
184
|
+
} catch (caught) {
|
|
185
|
+
summary.textContent = "";
|
|
186
|
+
error.write(caught?.message || "Could not render this audio.");
|
|
187
|
+
} finally {
|
|
188
|
+
if (current === token) {
|
|
189
|
+
busy = false;
|
|
190
|
+
convertButton.disabled = false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
drop.onFiles(open);
|
|
196
|
+
convertButton.addEventListener("click", convert);
|
|
197
|
+
clearButton.addEventListener("click", reset);
|
|
198
|
+
reset();
|
|
199
|
+
|
|
200
|
+
const shell = toolcard({
|
|
201
|
+
body: bench(
|
|
202
|
+
stack(
|
|
203
|
+
drop,
|
|
204
|
+
panel("Sample rate", rate),
|
|
205
|
+
panel("Channels", channels),
|
|
206
|
+
el("div", { class: "grid grid-cols-2 gap-3" },
|
|
207
|
+
panel("Start (s)", start),
|
|
208
|
+
panel("End (s)", end)),
|
|
209
|
+
normalize,
|
|
210
|
+
error
|
|
211
|
+
),
|
|
212
|
+
stack(summary, empty, wave,
|
|
213
|
+
el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
|
|
214
|
+
"Output is uncompressed WAV, 16 bit PCM. A three minute stereo song at 44.1 kHz lands near 30 MB, which is normal for the format."))
|
|
215
|
+
),
|
|
216
|
+
foot: actions(clearButton, convertButton),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
slot.append(shell.node);
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
unmount() {
|
|
223
|
+
token++;
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export default { mount };
|