@utilix-tech/sdk 0.27.0 → 0.29.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/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  Full release notes for all Utilix surfaces (browser tools, REST API, SDKs, MCP server) live at [utilix.tech/changelog](https://utilix.tech/changelog). This file tracks just this package.
4
4
 
5
+ ## 0.29.0
6
+ - Add `readPdfPageDimensions` (pdf-page-dimensions): each page's MediaBox width/height (points and inches), rotation, and orientation, walking the Root -> Pages -> Kids tree and inheriting from ancestor nodes, plus a common paper size match (Letter, A4, etc.).
7
+ - Add `parseIpv6Address` (ipv6-compressor): expand an IPv6 address to its full 8-group form or compress it to its RFC 5952 canonical shortest form, classify its address type, and handle an embedded IPv4 suffix or zone ID.
8
+
9
+ ## 0.28.0
10
+ - Add `readVideoInfo` (mp4-webm-video-info): duration, resolution, and video/audio codec identifiers from an MP4 (ISO BMFF moov box) or WebM (EBML/Matroska Segment) container header, with no frame decoding or transcoding.
11
+
5
12
  ## 0.27.0
6
13
  - Add `calculateRuleOf72` (rule-of-72-calculator): estimate years to double an investment from a rate, or the rate needed to double in a given number of years, alongside the exact logarithmic result.
7
14
  - Add `readFlacInfo` (flac-metadata-reader): sample rate, channels, bit depth, total samples, and duration from a FLAC file's STREAMINFO block, plus Vorbis comment tags when present.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @utilix-tech/sdk
2
2
 
3
- **578 developer utility functions for Node.js: runs locally, no API key required.**
3
+ **581 developer utility functions for Node.js: runs locally, no API key required.**
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@utilix-tech/sdk?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@utilix-tech/sdk)
6
6
  [![npm downloads](https://img.shields.io/npm/dm/@utilix-tech/sdk?style=flat-square)](https://www.npmjs.com/package/@utilix-tech/sdk)
@@ -372,7 +372,7 @@ calculateRuleOf72(8, "rate-to-years");
372
372
  import { getStatusCode, isValidIp, isValidIpv4, isValidIpv6,
373
373
  searchStatusCodes, buildGeoUrl, countryCodeToFlag,
374
374
  parseHar, validateSitemap, validateRobotsTxt,
375
- parseUserAgent, buildCacheControl } from "@utilix-tech/sdk/network";
375
+ parseUserAgent, buildCacheControl, parseIpv6Address } from "@utilix-tech/sdk/network";
376
376
 
377
377
  getStatusCode(404);
378
378
  // { code: 404, text: "Not Found", description: "...", category: "Client Error" }
@@ -406,6 +406,11 @@ validateRobotsTxt("User-agent: *\nDisallow: /admin\nSitemap: https://example.com
406
406
  // Build and explain a Cache-Control response header from directives
407
407
  buildCacheControl({ visibility: "public", maxAge: 3600, mustRevalidate: true });
408
408
  // { header: "public, max-age=3600, must-revalidate", directives: [...], warnings: [] }
409
+
410
+ // Expand/compress an IPv6 address (RFC 5952) and classify its address type
411
+ parseIpv6Address("2001:db8::ff00:42:8329");
412
+ // { expanded: "2001:0db8:0000:0000:0000:ff00:0042:8329", compressed: "2001:db8::ff00:42:8329",
413
+ // groups: [...], zoneId: null, addressType: "documentation" }
409
414
  ```
410
415
 
411
416
  ---
@@ -561,10 +566,10 @@ analyzeString("café"); // { length: 4, codePoints: [...], ... }
561
566
 
562
567
  ---
563
568
 
564
- ### `/media`: Image Header Parsing, EXIF, PDF, WAV, ID3, ICO & FLAC Metadata
569
+ ### `/media`: Image Header Parsing, EXIF, PDF Metadata & Page Dimensions, WAV, ID3, ICO, FLAC & Video Metadata
565
570
 
566
571
  ```ts
567
- import { readImageInfo, readExifData, readPdfMetadata, readWavInfo, readId3Tags, readIcoInfo, readFlacInfo } from "@utilix-tech/sdk/media";
572
+ import { readImageInfo, readExifData, readPdfMetadata, readPdfPageDimensions, readWavInfo, readId3Tags, readIcoInfo, readFlacInfo, readVideoInfo } from "@utilix-tech/sdk/media";
568
573
 
569
574
  // Reads format, dimensions, bit depth, and alpha channel straight from
570
575
  // file bytes: no decoding, no canvas, no image library.
@@ -584,6 +589,12 @@ const pdfBytes = await fs.promises.readFile("report.pdf");
584
589
  readPdfMetadata(new Uint8Array(pdfBytes));
585
590
  // { version: "1.7", pageCount: 12, title: "Q3 Report", author: "Jane Doe", ... }
586
591
 
592
+ // Lists each page's MediaBox width/height (points and inches), rotation, and
593
+ // orientation by walking the Root -> Pages -> Kids tree, inheriting from
594
+ // ancestor nodes as the PDF spec requires. Also flags common paper sizes.
595
+ readPdfPageDimensions(new Uint8Array(pdfBytes));
596
+ // { version: "1.7", pageCount: 12, pages: [{ pageNumber: 1, widthPt: 612, heightPt: 792, rotation: 0, orientation: "portrait", paperSize: "Letter" }, ...] }
597
+
587
598
  // Reads sample rate, channels, bit depth, and duration from a WAV file's
588
599
  // RIFF/fmt/data chunk headers: no audio library required.
589
600
  const wavBytes = await fs.promises.readFile("recording.wav");
@@ -609,9 +620,16 @@ readIcoInfo(new Uint8Array(icoBytes));
609
620
  const flacBytes = await fs.promises.readFile("track.flac");
610
621
  readFlacInfo(new Uint8Array(flacBytes));
611
622
  // { sampleRate: 44100, channels: 2, bitsPerSample: 16, durationSeconds: 214.7, artist: "Artist Name", ... }
623
+
624
+ // Reads duration, resolution, and video/audio codec identifiers from an
625
+ // MP4 (ISO BMFF moov box) or WebM (EBML/Matroska Segment) container
626
+ // header: no frame decoding, demuxing, or transcoding.
627
+ const videoBytes = await fs.promises.readFile("clip.mp4");
628
+ readVideoInfo(new Uint8Array(videoBytes));
629
+ // { format: "mp4", durationSeconds: 12.5, width: 1920, height: 1080, videoCodec: "avc1", audioCodec: "mp4a", ... }
612
630
  ```
613
631
 
614
- `readImageInfo` supports PNG, JPEG, GIF, WebP, and BMP. `readExifData` supports JPEG only. `readWavInfo` supports the canonical RIFF/WAVE container only. `readId3Tags` decodes ID3v2.3/2.4 and ID3v1/1.1 only (ID3v2.2 is detected but not decoded). `readIcoInfo` reads .ico icon files only and rejects .cur cursor files. `readFlacInfo` reads the native FLAC container only, not FLAC-in-Ogg.
632
+ `readImageInfo` supports PNG, JPEG, GIF, WebP, and BMP. `readExifData` supports JPEG only. `readWavInfo` supports the canonical RIFF/WAVE container only. `readId3Tags` decodes ID3v2.3/2.4 and ID3v1/1.1 only (ID3v2.2 is detected but not decoded). `readIcoInfo` reads .ico icon files only and rejects .cur cursor files. `readFlacInfo` reads the native FLAC container only, not FLAC-in-Ogg. `readVideoInfo` supports MP4 (ISO BMFF) and WebM (EBML/Matroska) containers only.
615
633
 
616
634
  ---
617
635
 
@@ -627,14 +645,14 @@ readFlacInfo(new Uint8Array(flacBytes));
627
645
  | `@utilix-tech/sdk/generators` | UUID v4/v7, ULID, passwords, strength check, fake data |
628
646
  | `@utilix-tech/sdk/time` | Date diff, timezone convert, cron parse, relative time |
629
647
  | `@utilix-tech/sdk/units` | Bytes, CSS px/rem, number bases, currency format, credit card/IBAN/barcode/ISBN validation, loan calculator, compound/simple interest calculators, VAT/sales tax calculator, tip/bill split calculator, break-even point calculator, Rule of 72 calculator |
630
- | `@utilix-tech/sdk/network` | HTTP status codes, IP validation, country flags, geolocation URLs, HAR file parsing, User-Agent parsing |
648
+ | `@utilix-tech/sdk/network` | HTTP status codes, IP validation, country flags, geolocation URLs, HAR file parsing, User-Agent parsing, IPv6 address compress/expand |
631
649
  | `@utilix-tech/sdk/api` | cURL build/parse, JWT decode/sign, JWKS parse, CORS headers, CSP header |
632
650
  | `@utilix-tech/sdk/code` | SQL format/minify, HTML format/minify, regex tester, GraphQL format, MIME type lookup, GitHub URL parsing |
633
651
  | `@utilix-tech/sdk/color` | Hex/RGB/HSL/HSV/CMYK convert, palette generation, contrast check |
634
652
  | `@utilix-tech/sdk/css` | CSS gradient, box-shadow, border-radius, keyframe animation generators |
635
653
  | `@utilix-tech/sdk/misc` | SVG optimize/sanitize, file size format, Unicode inspector |
636
654
  | `@utilix-tech/sdk/ai_agent` | Token estimate/trim, chunk text, extract URLs/JSON/keywords, sanitize HTML, flatten/merge JSON, dedupe lines, validate schema, PII/secret/injection detect, vector similarity |
637
- | `@utilix-tech/sdk/media` | Image format & dimension reading from raw bytes (PNG, JPEG, GIF, WebP, BMP); PDF metadata reading; WAV audio info; ID3 tags; ICO favicon inspection; FLAC metadata reading |
655
+ | `@utilix-tech/sdk/media` | Image format & dimension reading from raw bytes (PNG, JPEG, GIF, WebP, BMP); PDF metadata reading; PDF page dimension/orientation listing; WAV audio info; ID3 tags; ICO favicon inspection; FLAC metadata reading; MP4/WebM video info |
638
656
 
639
657
  ---
640
658
 
@@ -27,6 +27,7 @@ __export(network_exports, {
27
27
  parseDnsResponse: () => parseDnsResponse,
28
28
  parseGeoResponse: () => parseGeoResponse,
29
29
  parseHar: () => parseHar,
30
+ parseIpv6Address: () => parseIpv6Address,
30
31
  parseUserAgent: () => parseUserAgent,
31
32
  searchHeaders: () => searchHeaders,
32
33
  searchStatusCodes: () => searchStatusCodes,
@@ -1814,5 +1815,93 @@ function buildCacheControl(options = {}) {
1814
1815
  const snippet = `Cache-Control: ${header}`;
1815
1816
  return { header, headerName: "Cache-Control", snippet, directives, warnings };
1816
1817
  }
1818
+ function isValidIpv6HexGroup(g) {
1819
+ return /^[0-9a-fA-F]{1,4}$/.test(g);
1820
+ }
1821
+ function isValidIpv6Ipv4Quad(quad) {
1822
+ const parts = quad.split(".");
1823
+ if (parts.length !== 4) return false;
1824
+ return parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255);
1825
+ }
1826
+ function classifyIpv6(groups) {
1827
+ const first = parseInt(groups[0], 16);
1828
+ if (groups.every((g) => g === "0000")) return "unspecified";
1829
+ if (groups.slice(0, 7).every((g) => g === "0000") && groups[7] === "0001") return "loopback";
1830
+ if (groups.slice(0, 4).every((g) => g === "0000") && groups[4] === "0000" && groups[5] === "ffff") return "ipv4-mapped";
1831
+ if (groups[0] === "2001" && groups[1] === "0db8") return "documentation";
1832
+ if ((first & 65280) === 65280) return "multicast";
1833
+ if ((first & 65472) === 65152) return "link-local";
1834
+ if ((first & 65024) === 64512) return "unique-local";
1835
+ return "global-unicast";
1836
+ }
1837
+ function compressIpv6Groups(groups) {
1838
+ const short = groups.map((g) => g.replace(/^0+(?=.)/, ""));
1839
+ let bestStart = -1;
1840
+ let bestLen = 0;
1841
+ let curStart = -1;
1842
+ let curLen = 0;
1843
+ for (let i = 0; i < short.length; i++) {
1844
+ if (short[i] === "0") {
1845
+ if (curStart === -1) curStart = i;
1846
+ curLen++;
1847
+ if (curLen > bestLen) {
1848
+ bestLen = curLen;
1849
+ bestStart = curStart;
1850
+ }
1851
+ } else {
1852
+ curStart = -1;
1853
+ curLen = 0;
1854
+ }
1855
+ }
1856
+ if (bestLen < 2) return short.join(":");
1857
+ const before = short.slice(0, bestStart).join(":");
1858
+ const after = short.slice(bestStart + bestLen).join(":");
1859
+ return `${before}::${after}`;
1860
+ }
1861
+ function parseIpv6Address(input) {
1862
+ let s = input.trim();
1863
+ if (s.startsWith("[") && s.includes("]")) s = s.slice(1, s.indexOf("]"));
1864
+ let zoneId = null;
1865
+ const pctIdx = s.indexOf("%");
1866
+ if (pctIdx !== -1) {
1867
+ zoneId = s.slice(pctIdx + 1);
1868
+ s = s.slice(0, pctIdx);
1869
+ }
1870
+ if (!s) return { error: "Empty address" };
1871
+ const doubleColonCount = (s.match(/::/g) ?? []).length;
1872
+ if (doubleColonCount > 1) return { error: 'Address contains more than one "::"' };
1873
+ const ipv4Match = s.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
1874
+ if (ipv4Match) {
1875
+ const quad = ipv4Match[1];
1876
+ if (!isValidIpv6Ipv4Quad(quad)) return { error: `Invalid embedded IPv4 address: ${quad}` };
1877
+ const parts = quad.split(".").map(Number);
1878
+ const hi = (parts[0] << 8 | parts[1]).toString(16);
1879
+ const lo = (parts[2] << 8 | parts[3]).toString(16);
1880
+ s = s.slice(0, s.length - quad.length) + `${hi}:${lo}`;
1881
+ }
1882
+ let groups;
1883
+ if (s.includes("::")) {
1884
+ const [left, right] = s.split("::");
1885
+ const leftGroups = left ? left.split(":") : [];
1886
+ const rightGroups = right ? right.split(":") : [];
1887
+ const missing = 8 - (leftGroups.length + rightGroups.length);
1888
+ if (missing < 1) return { error: 'The "::" shorthand must represent at least one group of zeros' };
1889
+ if (![...leftGroups, ...rightGroups].every(isValidIpv6HexGroup)) return { error: "Invalid hex group in address" };
1890
+ groups = [...leftGroups, ...Array(missing).fill("0"), ...rightGroups];
1891
+ } else {
1892
+ groups = s.split(":");
1893
+ if (groups.length !== 8) return { error: `Expected 8 groups, got ${groups.length}` };
1894
+ if (!groups.every(isValidIpv6HexGroup)) return { error: "Invalid hex group in address" };
1895
+ }
1896
+ const expandedGroups = groups.map((g) => g.padStart(4, "0").toLowerCase());
1897
+ const suffix = zoneId ? `%${zoneId}` : "";
1898
+ return {
1899
+ expanded: expandedGroups.join(":") + suffix,
1900
+ compressed: compressIpv6Groups(expandedGroups) + suffix,
1901
+ groups: expandedGroups,
1902
+ zoneId,
1903
+ addressType: classifyIpv6(expandedGroups)
1904
+ };
1905
+ }
1817
1906
 
1818
- export { DNS_RECORD_TYPES, HTTP_HEADERS, STATUS_CODES, buildCacheControl, buildDnsQueryUrl, buildGeoUrl, buildMapUrl, countryCodeToFlag, formatCoordinates, formatTtl, getByCategory, getHeader, getHeadersByCategory, getStatusByCategory, getStatusCode, isClientError, isServerError, isSuccess, isValidIp, isValidIpv4, isValidIpv6, network_exports, parseDnsResponse, parseGeoResponse, parseHar, parseUserAgent, searchHeaders, searchStatusCodes, statusCodeToText, typeNumberToLabel, validateDomain, validateHeaderName, validateHeaderValue, validateRobotsTxt, validateSitemap };
1907
+ export { DNS_RECORD_TYPES, HTTP_HEADERS, STATUS_CODES, buildCacheControl, buildDnsQueryUrl, buildGeoUrl, buildMapUrl, countryCodeToFlag, formatCoordinates, formatTtl, getByCategory, getHeader, getHeadersByCategory, getStatusByCategory, getStatusCode, isClientError, isServerError, isSuccess, isValidIp, isValidIpv4, isValidIpv6, network_exports, parseDnsResponse, parseGeoResponse, parseHar, parseIpv6Address, parseUserAgent, searchHeaders, searchStatusCodes, statusCodeToText, typeNumberToLabel, validateDomain, validateHeaderName, validateHeaderValue, validateRobotsTxt, validateSitemap };
@@ -9,6 +9,8 @@ __export(media_exports, {
9
9
  readId3Tags: () => readId3Tags,
10
10
  readImageInfo: () => readImageInfo,
11
11
  readPdfMetadata: () => readPdfMetadata,
12
+ readPdfPageDimensions: () => readPdfPageDimensions,
13
+ readVideoInfo: () => readVideoInfo,
12
14
  readWavInfo: () => readWavInfo
13
15
  });
14
16
  function readU32BE(bytes, offset) {
@@ -467,6 +469,116 @@ function readPdfMetadata(bytes) {
467
469
  encrypted
468
470
  };
469
471
  }
472
+ var PDF_COMMON_PAPER_SIZES = [
473
+ { name: "Letter", widthPt: 612, heightPt: 792 },
474
+ { name: "Legal", widthPt: 612, heightPt: 1008 },
475
+ { name: "Tabloid", widthPt: 792, heightPt: 1224 },
476
+ { name: "A3", widthPt: 842, heightPt: 1191 },
477
+ { name: "A4", widthPt: 595, heightPt: 842 },
478
+ { name: "A5", widthPt: 420, heightPt: 595 },
479
+ { name: "B5", widthPt: 499, heightPt: 709 }
480
+ ];
481
+ function matchPaperSize(widthPt, heightPt) {
482
+ for (const size of PDF_COMMON_PAPER_SIZES) {
483
+ const w = Math.abs(widthPt - size.widthPt) <= 1;
484
+ const h = Math.abs(heightPt - size.heightPt) <= 1;
485
+ const wSwapped = Math.abs(widthPt - size.heightPt) <= 1;
486
+ const hSwapped = Math.abs(heightPt - size.widthPt) <= 1;
487
+ if (w && h || wSwapped && hSwapped) return size.name;
488
+ }
489
+ return null;
490
+ }
491
+ function parsePdfMediaBox(dict) {
492
+ const m = dict.match(/\/MediaBox\s*\[\s*(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s*\]/);
493
+ if (!m) return null;
494
+ return [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
495
+ }
496
+ function parsePdfRotate(dict) {
497
+ const m = dict.match(/\/Rotate\s+(-?\d+)/);
498
+ return m ? Number(m[1]) : null;
499
+ }
500
+ function buildPdfPageDimension(pageNumber, mediaBox, rotate) {
501
+ const rotation = (rotate % 360 + 360) % 360;
502
+ if (!mediaBox) {
503
+ return { pageNumber, widthPt: null, heightPt: null, widthIn: null, heightIn: null, rotation, orientation: null, paperSize: null };
504
+ }
505
+ const [llx, lly, urx, ury] = mediaBox;
506
+ let widthPt = Math.abs(urx - llx);
507
+ let heightPt = Math.abs(ury - lly);
508
+ if (rotation === 90 || rotation === 270) [widthPt, heightPt] = [heightPt, widthPt];
509
+ const orientation = widthPt === heightPt ? "square" : widthPt > heightPt ? "landscape" : "portrait";
510
+ return {
511
+ pageNumber,
512
+ widthPt: round2(widthPt),
513
+ heightPt: round2(heightPt),
514
+ widthIn: round2(widthPt / 72),
515
+ heightIn: round2(heightPt / 72),
516
+ rotation,
517
+ orientation,
518
+ paperSize: matchPaperSize(widthPt, heightPt)
519
+ };
520
+ }
521
+ function walkPdfPageTree(objMap, nodeBody, inherited, visited, nodeKey, out) {
522
+ if (visited.has(nodeKey)) return;
523
+ visited.add(nodeKey);
524
+ const mediaBox = parsePdfMediaBox(nodeBody) ?? inherited.mediaBox;
525
+ const rotateMatch = parsePdfRotate(nodeBody);
526
+ const rotate = rotateMatch !== null ? rotateMatch : inherited.rotate;
527
+ if (/\/Type\s*\/Page(?!s)\b/.test(nodeBody)) {
528
+ out.push(buildPdfPageDimension(out.length + 1, mediaBox, rotate));
529
+ return;
530
+ }
531
+ const kidsMatch = nodeBody.match(/\/Kids\s*\[([^\]]*)\]/);
532
+ if (!kidsMatch) return;
533
+ const kidRefs = [...kidsMatch[1].matchAll(/(\d+)\s+(\d+)\s+R/g)];
534
+ for (const ref of kidRefs) {
535
+ const key = `${ref[1]} ${ref[2]}`;
536
+ const body = objMap.get(key);
537
+ if (body) walkPdfPageTree(objMap, body, { mediaBox, rotate }, visited, key, out);
538
+ }
539
+ }
540
+ function readPdfPageDimensions(bytes) {
541
+ if (bytes.length < 8) return { error: "File too small to be a PDF" };
542
+ const header = bytesToLatin1(bytes.subarray(0, 16));
543
+ const versionMatch = header.match(/%PDF-(\d\.\d)/);
544
+ if (!versionMatch) return { error: "Not a valid PDF file (missing %PDF header)" };
545
+ const version = versionMatch[1];
546
+ const text = bytesToLatin1(bytes);
547
+ const objBlocks = [...text.matchAll(/(\d+)\s+(\d+)\s+obj([\s\S]*?)endobj/g)];
548
+ const objMap = /* @__PURE__ */ new Map();
549
+ for (const b of objBlocks) objMap.set(`${b[1]} ${b[2]}`, b[3]);
550
+ const out = [];
551
+ const visited = /* @__PURE__ */ new Set();
552
+ let pagesRootBody = null;
553
+ const trailerMatches = [...text.matchAll(/trailer\s*<<([\s\S]*?)>>/g)];
554
+ if (trailerMatches.length > 0) {
555
+ const trailer = trailerMatches[trailerMatches.length - 1][1];
556
+ const rootRef = trailer.match(/\/Root\s+(\d+)\s+(\d+)\s+R/);
557
+ if (rootRef) {
558
+ const catalogBody = objMap.get(`${rootRef[1]} ${rootRef[2]}`);
559
+ const pagesRef = catalogBody?.match(/\/Pages\s+(\d+)\s+(\d+)\s+R/);
560
+ if (pagesRef) pagesRootBody = objMap.get(`${pagesRef[1]} ${pagesRef[2]}`) ?? null;
561
+ }
562
+ }
563
+ if (!pagesRootBody) {
564
+ for (const b of objBlocks) {
565
+ if (/\/Type\s*\/Pages\b/.test(b[3]) && !/\/Parent\s+\d+\s+\d+\s+R/.test(b[3])) {
566
+ pagesRootBody = b[3];
567
+ break;
568
+ }
569
+ }
570
+ }
571
+ if (pagesRootBody) walkPdfPageTree(objMap, pagesRootBody, { mediaBox: null, rotate: 0 }, visited, "root", out);
572
+ if (out.length === 0) {
573
+ for (const b of objBlocks) {
574
+ if (/\/Type\s*\/Page(?!s)\b/.test(b[3])) {
575
+ out.push(buildPdfPageDimension(out.length + 1, parsePdfMediaBox(b[3]), parsePdfRotate(b[3]) ?? 0));
576
+ }
577
+ }
578
+ }
579
+ if (out.length === 0) return { error: "Could not locate any page objects in this PDF" };
580
+ return { version, pageCount: out.length, pages: out };
581
+ }
470
582
  var AUDIO_FORMAT_LABELS = {
471
583
  1: "PCM",
472
584
  3: "IEEE float",
@@ -860,5 +972,253 @@ function readFlacInfo(bytes) {
860
972
  }
861
973
  return result;
862
974
  }
975
+ function readU64BE(bytes, offset) {
976
+ return readU32BE(bytes, offset) * 4294967296 + readU32BE(bytes, offset + 4);
977
+ }
978
+ function readMp4Boxes(bytes, start, end) {
979
+ const boxes = [];
980
+ let offset = start;
981
+ while (offset + 8 <= end) {
982
+ let size = readU32BE(bytes, offset);
983
+ const type = readAscii4(bytes, offset + 4);
984
+ let headerSize = 8;
985
+ if (size === 1) {
986
+ if (offset + 16 > end) break;
987
+ size = readU64BE(bytes, offset + 8);
988
+ headerSize = 16;
989
+ } else if (size === 0) {
990
+ size = end - offset;
991
+ }
992
+ if (size < headerSize || offset + size > end) break;
993
+ boxes.push({ type, start: offset + headerSize, end: offset + size });
994
+ offset += size;
995
+ }
996
+ return boxes;
997
+ }
998
+ function findMp4Box(boxes, type) {
999
+ return boxes.find((b) => b.type === type);
1000
+ }
1001
+ function parseMp4Mvhd(bytes, box) {
1002
+ const o = box.start;
1003
+ if (o >= box.end) return null;
1004
+ const version = bytes[o];
1005
+ if (version === 1) {
1006
+ if (o + 32 > box.end) return null;
1007
+ return { timescale: readU32BE(bytes, o + 20), duration: readU64BE(bytes, o + 24) };
1008
+ }
1009
+ if (o + 20 > box.end) return null;
1010
+ return { timescale: readU32BE(bytes, o + 12), duration: readU32BE(bytes, o + 16) };
1011
+ }
1012
+ function parseMp4Tkhd(bytes, box) {
1013
+ const o = box.start;
1014
+ if (o >= box.end) return null;
1015
+ const version = bytes[o];
1016
+ const fixedFieldsSize = version === 1 ? 4 + 8 + 8 + 4 + 4 + 8 : 4 + 4 + 4 + 4 + 4 + 4;
1017
+ const afterDuration = o + fixedFieldsSize;
1018
+ const matrixStart = afterDuration + 8 + 2 + 2 + 2 + 2;
1019
+ const widthOffset = matrixStart + 36;
1020
+ if (widthOffset + 8 > box.end) return null;
1021
+ return { width: readU32BE(bytes, widthOffset) / 65536, height: readU32BE(bytes, widthOffset + 4) / 65536 };
1022
+ }
1023
+ function parseMp4Hdlr(bytes, box) {
1024
+ const o = box.start;
1025
+ if (o + 12 > box.end) return null;
1026
+ return readAscii4(bytes, o + 8);
1027
+ }
1028
+ function parseMp4StsdCodec(bytes, box) {
1029
+ const o = box.start;
1030
+ if (o + 8 > box.end) return null;
1031
+ const entryCount = readU32BE(bytes, o + 4);
1032
+ if (entryCount < 1) return null;
1033
+ const entryStart = o + 8;
1034
+ if (entryStart + 8 > box.end) return null;
1035
+ return readAscii4(bytes, entryStart + 4);
1036
+ }
1037
+ function parseMp4StsdVideoDims(bytes, box) {
1038
+ const o = box.start;
1039
+ if (o + 8 > box.end) return null;
1040
+ const entryCount = readU32BE(bytes, o + 4);
1041
+ if (entryCount < 1) return null;
1042
+ const entryStart = o + 8;
1043
+ if (entryStart + 36 > box.end) return null;
1044
+ return {
1045
+ width: bytes[entryStart + 32] << 8 | bytes[entryStart + 33],
1046
+ height: bytes[entryStart + 34] << 8 | bytes[entryStart + 35]
1047
+ };
1048
+ }
1049
+ function parseMp4(bytes) {
1050
+ const topBoxes = readMp4Boxes(bytes, 0, bytes.length);
1051
+ const ftyp = findMp4Box(topBoxes, "ftyp");
1052
+ const moov = findMp4Box(topBoxes, "moov");
1053
+ if (!moov) return { error: "No moov box found (not a valid or fully-downloaded MP4 file)" };
1054
+ const result = { format: "mp4" };
1055
+ if (ftyp && ftyp.start + 4 <= ftyp.end) result.majorBrand = readAscii4(bytes, ftyp.start);
1056
+ const moovBoxes = readMp4Boxes(bytes, moov.start, moov.end);
1057
+ const mvhd = findMp4Box(moovBoxes, "mvhd");
1058
+ if (mvhd) {
1059
+ const info = parseMp4Mvhd(bytes, mvhd);
1060
+ if (info && info.timescale > 0) result.durationSeconds = Math.round(info.duration / info.timescale * 1e3) / 1e3;
1061
+ }
1062
+ for (const trak of moovBoxes.filter((b) => b.type === "trak")) {
1063
+ const trakBoxes = readMp4Boxes(bytes, trak.start, trak.end);
1064
+ const mdia = findMp4Box(trakBoxes, "mdia");
1065
+ if (!mdia) continue;
1066
+ const mdiaBoxes = readMp4Boxes(bytes, mdia.start, mdia.end);
1067
+ const hdlr = findMp4Box(mdiaBoxes, "hdlr");
1068
+ const handlerType = hdlr ? parseMp4Hdlr(bytes, hdlr) : null;
1069
+ const minf = findMp4Box(mdiaBoxes, "minf");
1070
+ if (!minf) continue;
1071
+ const stbl = findMp4Box(readMp4Boxes(bytes, minf.start, minf.end), "stbl");
1072
+ if (!stbl) continue;
1073
+ const stsd = findMp4Box(readMp4Boxes(bytes, stbl.start, stbl.end), "stsd");
1074
+ const codec = stsd ? parseMp4StsdCodec(bytes, stsd) : null;
1075
+ if (handlerType === "vide") {
1076
+ if (codec) result.videoCodec = codec;
1077
+ const tkhd = findMp4Box(trakBoxes, "tkhd");
1078
+ const dims = tkhd ? parseMp4Tkhd(bytes, tkhd) : null;
1079
+ if (dims && dims.width > 0 && dims.height > 0) {
1080
+ result.width = Math.round(dims.width);
1081
+ result.height = Math.round(dims.height);
1082
+ } else if (stsd) {
1083
+ const pixelDims = parseMp4StsdVideoDims(bytes, stsd);
1084
+ if (pixelDims && pixelDims.width > 0 && pixelDims.height > 0) {
1085
+ result.width = pixelDims.width;
1086
+ result.height = pixelDims.height;
1087
+ }
1088
+ }
1089
+ } else if (handlerType === "soun") {
1090
+ if (codec) result.audioCodec = codec;
1091
+ }
1092
+ }
1093
+ if (result.durationSeconds === void 0 && !result.videoCodec && !result.audioCodec) {
1094
+ return { error: "Could not find any usable track metadata in this MP4 file's moov box" };
1095
+ }
1096
+ return result;
1097
+ }
1098
+ function ebmlVintLength(firstByte) {
1099
+ if (firstByte === 0) return 0;
1100
+ let mask = 128;
1101
+ let length = 1;
1102
+ while (length <= 8 && (firstByte & mask) === 0) {
1103
+ mask >>= 1;
1104
+ length++;
1105
+ }
1106
+ return length;
1107
+ }
1108
+ function readEbmlId(bytes, offset) {
1109
+ if (offset >= bytes.length) return null;
1110
+ const length = ebmlVintLength(bytes[offset]);
1111
+ if (length === 0 || offset + length > bytes.length) return null;
1112
+ let id = 0;
1113
+ for (let i = 0; i < length; i++) id = id * 256 + bytes[offset + i];
1114
+ return { id, length };
1115
+ }
1116
+ function readEbmlSize(bytes, offset) {
1117
+ if (offset >= bytes.length) return null;
1118
+ const length = ebmlVintLength(bytes[offset]);
1119
+ if (length === 0 || offset + length > bytes.length) return null;
1120
+ const mask = 255 >> length;
1121
+ let value = bytes[offset] & mask;
1122
+ let allOnes = value === mask;
1123
+ for (let i = 1; i < length; i++) {
1124
+ value = value * 256 + bytes[offset + i];
1125
+ if (bytes[offset + i] !== 255) allOnes = false;
1126
+ }
1127
+ return { value, length, unknown: allOnes };
1128
+ }
1129
+ function readEbmlUint(bytes, offset, length) {
1130
+ let value = 0;
1131
+ for (let i = 0; i < length; i++) value = value * 256 + bytes[offset + i];
1132
+ return value;
1133
+ }
1134
+ function readEbmlFloat(bytes, offset, length) {
1135
+ if (offset + length > bytes.length) return 0;
1136
+ const view = new DataView(bytes.buffer, bytes.byteOffset + offset, length);
1137
+ if (length === 4) return view.getFloat32(0, false);
1138
+ if (length === 8) return view.getFloat64(0, false);
1139
+ return 0;
1140
+ }
1141
+ function ebmlChildren(bytes, start, end) {
1142
+ const children = [];
1143
+ let offset = start;
1144
+ while (offset < end) {
1145
+ const idInfo = readEbmlId(bytes, offset);
1146
+ if (!idInfo) break;
1147
+ const sizeInfo = readEbmlSize(bytes, offset + idInfo.length);
1148
+ if (!sizeInfo) break;
1149
+ const dataStart = offset + idInfo.length + sizeInfo.length;
1150
+ const dataEnd = sizeInfo.unknown ? end : Math.min(dataStart + sizeInfo.value, end);
1151
+ children.push({ id: idInfo.id, dataStart, dataEnd });
1152
+ offset = dataEnd;
1153
+ }
1154
+ return children;
1155
+ }
1156
+ var EBML_ID_SEGMENT = 408125543;
1157
+ var EBML_ID_INFO = 357149030;
1158
+ var EBML_ID_TIMECODE_SCALE = 2807729;
1159
+ var EBML_ID_DURATION = 17545;
1160
+ var EBML_ID_TRACKS = 374648427;
1161
+ var EBML_ID_TRACK_ENTRY = 174;
1162
+ var EBML_ID_TRACK_TYPE = 131;
1163
+ var EBML_ID_CODEC_ID = 134;
1164
+ var EBML_ID_VIDEO = 224;
1165
+ var EBML_ID_PIXEL_WIDTH = 176;
1166
+ var EBML_ID_PIXEL_HEIGHT = 186;
1167
+ var EBML_ID_CLUSTER = 524531317;
1168
+ function readVideoInfo(bytes) {
1169
+ if (bytes.length < 12) return { error: "File is too small to be a valid video file" };
1170
+ if (readAscii4(bytes, 4) === "ftyp") return parseMp4(bytes);
1171
+ if (bytes[0] === 26 && bytes[1] === 69 && bytes[2] === 223 && bytes[3] === 163) return parseWebm(bytes);
1172
+ return { error: 'Not a recognized MP4 or WebM file (missing an "ftyp" box or an EBML header signature)' };
1173
+ }
1174
+ function parseWebm(bytes) {
1175
+ const top = ebmlChildren(bytes, 0, bytes.length);
1176
+ const segment = top.find((c) => c.id === EBML_ID_SEGMENT);
1177
+ if (!segment) return { error: "No Segment element found (not a valid WebM/Matroska file)" };
1178
+ const result = { format: "webm" };
1179
+ let timecodeScale = 1e6;
1180
+ let rawDuration;
1181
+ for (const child of ebmlChildren(bytes, segment.dataStart, segment.dataEnd)) {
1182
+ if (child.id === EBML_ID_CLUSTER) break;
1183
+ if (child.id === EBML_ID_INFO) {
1184
+ for (const f of ebmlChildren(bytes, child.dataStart, child.dataEnd)) {
1185
+ if (f.id === EBML_ID_TIMECODE_SCALE) timecodeScale = readEbmlUint(bytes, f.dataStart, f.dataEnd - f.dataStart) || 1e6;
1186
+ else if (f.id === EBML_ID_DURATION) rawDuration = readEbmlFloat(bytes, f.dataStart, f.dataEnd - f.dataStart);
1187
+ }
1188
+ } else if (child.id === EBML_ID_TRACKS) {
1189
+ for (const entry of ebmlChildren(bytes, child.dataStart, child.dataEnd)) {
1190
+ if (entry.id !== EBML_ID_TRACK_ENTRY) continue;
1191
+ let trackType;
1192
+ let codecId;
1193
+ let pixelWidth;
1194
+ let pixelHeight;
1195
+ for (const f of ebmlChildren(bytes, entry.dataStart, entry.dataEnd)) {
1196
+ if (f.id === EBML_ID_TRACK_TYPE) trackType = readEbmlUint(bytes, f.dataStart, f.dataEnd - f.dataStart);
1197
+ else if (f.id === EBML_ID_CODEC_ID) codecId = Buffer.from(bytes.subarray(f.dataStart, f.dataEnd)).toString("ascii").replace(/\0+$/, "");
1198
+ else if (f.id === EBML_ID_VIDEO) {
1199
+ for (const v of ebmlChildren(bytes, f.dataStart, f.dataEnd)) {
1200
+ if (v.id === EBML_ID_PIXEL_WIDTH) pixelWidth = readEbmlUint(bytes, v.dataStart, v.dataEnd - v.dataStart);
1201
+ else if (v.id === EBML_ID_PIXEL_HEIGHT) pixelHeight = readEbmlUint(bytes, v.dataStart, v.dataEnd - v.dataStart);
1202
+ }
1203
+ }
1204
+ }
1205
+ if (trackType === 1) {
1206
+ if (codecId) result.videoCodec = codecId;
1207
+ if (pixelWidth) result.width = pixelWidth;
1208
+ if (pixelHeight) result.height = pixelHeight;
1209
+ } else if (trackType === 2) {
1210
+ if (codecId) result.audioCodec = codecId;
1211
+ }
1212
+ }
1213
+ }
1214
+ }
1215
+ if (rawDuration !== void 0 && timecodeScale > 0) {
1216
+ result.durationSeconds = Math.round(rawDuration * timecodeScale / 1e9 * 1e3) / 1e3;
1217
+ }
1218
+ if (result.durationSeconds === void 0 && !result.videoCodec && !result.audioCodec) {
1219
+ return { error: "Could not find any usable metadata in this WebM file's Segment element" };
1220
+ }
1221
+ return result;
1222
+ }
863
1223
 
864
- export { media_exports, readExifData, readFlacInfo, readIcoInfo, readId3Tags, readImageInfo, readPdfMetadata, readWavInfo };
1224
+ export { media_exports, readExifData, readFlacInfo, readIcoInfo, readId3Tags, readImageInfo, readPdfMetadata, readPdfPageDimensions, readVideoInfo, readWavInfo };