fidelity-kit 0.1.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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +15 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/bin.d.ts +3 -0
  5. package/dist/bin.d.ts.map +1 -0
  6. package/dist/bin.js +13 -0
  7. package/dist/bin.js.map +1 -0
  8. package/dist/commands/build.d.ts +8 -0
  9. package/dist/commands/build.d.ts.map +1 -0
  10. package/dist/commands/build.js +32 -0
  11. package/dist/commands/build.js.map +1 -0
  12. package/dist/commands/dev.d.ts +10 -0
  13. package/dist/commands/dev.d.ts.map +1 -0
  14. package/dist/commands/dev.js +17 -0
  15. package/dist/commands/dev.js.map +1 -0
  16. package/dist/commands/hash.d.ts +6 -0
  17. package/dist/commands/hash.d.ts.map +1 -0
  18. package/dist/commands/hash.js +23 -0
  19. package/dist/commands/hash.js.map +1 -0
  20. package/dist/commands/process.d.ts +6 -0
  21. package/dist/commands/process.d.ts.map +1 -0
  22. package/dist/commands/process.js +22 -0
  23. package/dist/commands/process.js.map +1 -0
  24. package/dist/commands/serve.d.ts +14 -0
  25. package/dist/commands/serve.d.ts.map +1 -0
  26. package/dist/commands/serve.js +28 -0
  27. package/dist/commands/serve.js.map +1 -0
  28. package/dist/core/compare.d.ts +32 -0
  29. package/dist/core/compare.d.ts.map +1 -0
  30. package/dist/core/compare.js +81 -0
  31. package/dist/core/compare.js.map +1 -0
  32. package/dist/core/etag.d.ts +39 -0
  33. package/dist/core/etag.d.ts.map +1 -0
  34. package/dist/core/etag.js +121 -0
  35. package/dist/core/etag.js.map +1 -0
  36. package/dist/core/hash.d.ts +30 -0
  37. package/dist/core/hash.d.ts.map +1 -0
  38. package/dist/core/hash.js +65 -0
  39. package/dist/core/hash.js.map +1 -0
  40. package/dist/core/hash.test.d.ts +2 -0
  41. package/dist/core/hash.test.d.ts.map +1 -0
  42. package/dist/core/hash.test.js +71 -0
  43. package/dist/core/hash.test.js.map +1 -0
  44. package/dist/core/index.d.ts +8 -0
  45. package/dist/core/index.d.ts.map +1 -0
  46. package/dist/core/index.js +8 -0
  47. package/dist/core/index.js.map +1 -0
  48. package/dist/core/paths.d.ts +7 -0
  49. package/dist/core/paths.d.ts.map +1 -0
  50. package/dist/core/paths.js +12 -0
  51. package/dist/core/paths.js.map +1 -0
  52. package/dist/core/process.d.ts +24 -0
  53. package/dist/core/process.d.ts.map +1 -0
  54. package/dist/core/process.js +65 -0
  55. package/dist/core/process.js.map +1 -0
  56. package/dist/core/scan.d.ts +26 -0
  57. package/dist/core/scan.d.ts.map +1 -0
  58. package/dist/core/scan.js +55 -0
  59. package/dist/core/scan.js.map +1 -0
  60. package/dist/core/schema.d.ts +23 -0
  61. package/dist/core/schema.d.ts.map +1 -0
  62. package/dist/core/schema.js +19 -0
  63. package/dist/core/schema.js.map +1 -0
  64. package/dist/core/suite.test.d.ts +2 -0
  65. package/dist/core/suite.test.d.ts.map +1 -0
  66. package/dist/core/suite.test.js +41 -0
  67. package/dist/core/suite.test.js.map +1 -0
  68. package/dist/index.d.ts +5 -0
  69. package/dist/index.d.ts.map +1 -0
  70. package/dist/index.js +29 -0
  71. package/dist/index.js.map +1 -0
  72. package/dist/run.d.ts +11 -0
  73. package/dist/run.d.ts.map +1 -0
  74. package/dist/run.js +23 -0
  75. package/dist/run.js.map +1 -0
  76. package/dist/server.d.ts +34 -0
  77. package/dist/server.d.ts.map +1 -0
  78. package/dist/server.js +90 -0
  79. package/dist/server.js.map +1 -0
  80. package/dist/server.test.d.ts +2 -0
  81. package/dist/server.test.d.ts.map +1 -0
  82. package/dist/server.test.js +162 -0
  83. package/dist/server.test.js.map +1 -0
  84. package/package.json +44 -0
  85. package/viewer/assets/ViewControls-hn4PfGV3.js +54 -0
  86. package/viewer/assets/_-BAkV94Fq.js +1 -0
  87. package/viewer/assets/index-D8YJNCs_.css +2 -0
  88. package/viewer/assets/index-D9G4C6Ns.js +11 -0
  89. package/viewer/assets/routes-DmeR6gGl.js +1 -0
  90. package/viewer/index.html +13 -0
@@ -0,0 +1,23 @@
1
+ import { availableParallelism } from 'node:os';
2
+ import { defineCommand } from 'yargs-file-commands';
3
+ import { hashSuite } from '../core/index.js';
4
+ export const command = defineCommand({
5
+ command: 'hash <root>',
6
+ describe: 'Write image-hashes.json so `serve` can use immutable, content-hashed image URLs from the first request',
7
+ builder: (yargs) => yargs
8
+ .positional('root', {
9
+ type: 'string',
10
+ demandOption: true,
11
+ describe: 'Suite results directory (contains fidelity.json)',
12
+ })
13
+ .option('concurrency', {
14
+ type: 'number',
15
+ default: availableParallelism(),
16
+ describe: 'Files hashed in parallel (default: os.availableParallelism())',
17
+ }),
18
+ handler: async (argv) => {
19
+ const r = await hashSuite(argv.root, { concurrency: argv.concurrency });
20
+ console.log(`${r.hashed} hashed, ${r.reused} reused, ${r.total} total`);
21
+ },
22
+ });
23
+ //# sourceMappingURL=hash.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hash.js","sourceRoot":"","sources":["../../src/commands/hash.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAE7C,MAAM,CAAC,MAAM,OAAO,GAAG,aAAa,CAAC;IACnC,OAAO,EAAE,aAAa;IACtB,QAAQ,EAAE,wGAAwG;IAClH,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE,CACjB,KAAK;SACF,UAAU,CAAC,MAAM,EAAE;QAClB,IAAI,EAAE,QAAQ;QACd,YAAY,EAAE,IAAI;QAClB,QAAQ,EAAE,kDAAkD;KAC7D,CAAC;SACD,MAAM,CAAC,aAAa,EAAE;QACrB,IAAI,EAAE,QAAQ;QACd,OAAO,EAAE,oBAAoB,EAAE;QAC/B,QAAQ,EAAE,+DAA+D;KAC1E,CAAC;IACN,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE;QACtB,MAAM,CAAC,GAAG,MAAM,SAAS,CAAC,IAAI,CAAC,IAAI,EAAE,EAAE,WAAW,EAAE,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC;QACxE,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,MAAM,YAAY,CAAC,CAAC,MAAM,YAAY,CAAC,CAAC,KAAK,QAAQ,CAAC,CAAC;IAC1E,CAAC;CACF,CAAC,CAAC"}
@@ -0,0 +1,6 @@
1
+ export declare const command: import("yargs-file-commands").DefineCommandResult<{}, {
2
+ root: string;
3
+ } & {
4
+ force: boolean;
5
+ }>;
6
+ //# sourceMappingURL=process.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"process.d.ts","sourceRoot":"","sources":["../../src/commands/process.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,OAAO;;;;EAiBlB,CAAC"}
@@ -0,0 +1,22 @@
1
+ import { processSuite } from '../core/index.js';
2
+ import { defineCommand } from 'yargs-file-commands';
3
+ export const command = defineCommand({
4
+ command: 'process <root>',
5
+ describe: 'Compute metrics + delta images for stale pairs and write index.json',
6
+ builder: (yargs) => yargs
7
+ .positional('root', {
8
+ type: 'string',
9
+ demandOption: true,
10
+ describe: 'Suite results directory (contains fidelity.json)',
11
+ })
12
+ .option('force', { type: 'boolean', default: false, describe: 'Recompute even when up to date' }),
13
+ handler: async (argv) => {
14
+ const r = await processSuite(argv.root, { force: argv.force, onCompute: (f) => console.log('wrote', f) });
15
+ console.log(`${r.computed} computed, ${r.skipped} up to date, ${r.failed.length} failed`);
16
+ for (const f of r.failed)
17
+ console.error(`FAILED ${f.file}: ${f.error}`);
18
+ if (r.failed.length)
19
+ process.exitCode = 1;
20
+ },
21
+ });
22
+ //# sourceMappingURL=process.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"process.js","sourceRoot":"","sources":["../../src/commands/process.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAChD,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEpD,MAAM,CAAC,MAAM,OAAO,GAAG,aAAa,CAAC;IACnC,OAAO,EAAE,gBAAgB;IACzB,QAAQ,EAAE,qEAAqE;IAC/E,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE,CACjB,KAAK;SACF,UAAU,CAAC,MAAM,EAAE;QAClB,IAAI,EAAE,QAAQ;QACd,YAAY,EAAE,IAAI;QAClB,QAAQ,EAAE,kDAAkD;KAC7D,CAAC;SACD,MAAM,CAAC,OAAO,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,gCAAgC,EAAE,CAAC;IACrG,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE;QACtB,MAAM,CAAC,GAAG,MAAM,YAAY,CAAC,IAAI,CAAC,IAAI,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,SAAS,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC;QAC1G,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,QAAQ,cAAc,CAAC,CAAC,OAAO,gBAAgB,CAAC,CAAC,MAAM,CAAC,MAAM,SAAS,CAAC,CAAC;QAC1F,KAAK,MAAM,CAAC,IAAI,CAAC,CAAC,MAAM;YAAE,OAAO,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,IAAI,KAAK,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC;QACxE,IAAI,CAAC,CAAC,MAAM,CAAC,MAAM;YAAE,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;IAC5C,CAAC;CACF,CAAC,CAAC"}
@@ -0,0 +1,14 @@
1
+ export declare const command: import("yargs-file-commands").DefineCommandResult<{}, {
2
+ root: string;
3
+ } & {
4
+ port: number;
5
+ } & {
6
+ host: string;
7
+ } & {
8
+ "max-age": number;
9
+ } & {
10
+ "stale-while-revalidate": number;
11
+ } & {
12
+ process: boolean;
13
+ }>;
14
+ //# sourceMappingURL=serve.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve.d.ts","sourceRoot":"","sources":["../../src/commands/serve.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,OAAO;;;;;;;;;;;;EAwBlB,CAAC"}
@@ -0,0 +1,28 @@
1
+ import { defineCommand } from 'yargs-file-commands';
2
+ import { run } from '../run.js';
3
+ import { defaultCachePolicy } from '../server.js';
4
+ export const command = defineCommand({
5
+ command: 'serve <root>',
6
+ describe: 'Serve the viewer for a results directory (lazy ETags and shared-cache headers)',
7
+ builder: (yargs) => yargs
8
+ .positional('root', {
9
+ type: 'string',
10
+ demandOption: true,
11
+ describe: 'Suite results directory (contains fidelity.json)',
12
+ })
13
+ .option('port', { type: 'number', default: 3000, describe: 'Port to listen on' })
14
+ .option('host', { type: 'string', default: 'localhost', describe: 'Interface to bind (0.0.0.0 for all)' })
15
+ .option('max-age', {
16
+ type: 'number',
17
+ default: defaultCachePolicy.maxAge,
18
+ describe: 'Seconds images are cached (browsers and CDNs) without revalidation',
19
+ })
20
+ .option('stale-while-revalidate', {
21
+ type: 'number',
22
+ default: defaultCachePolicy.staleWhileRevalidate,
23
+ describe: 'Seconds a stale image may be served while it refreshes in the background',
24
+ })
25
+ .option('process', { type: 'boolean', default: true, describe: 'Refresh stale metrics/deltas before serving' }),
26
+ handler: (argv) => run(argv, false),
27
+ });
28
+ //# sourceMappingURL=serve.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve.js","sourceRoot":"","sources":["../../src/commands/serve.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAChC,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAElD,MAAM,CAAC,MAAM,OAAO,GAAG,aAAa,CAAC;IACnC,OAAO,EAAE,cAAc;IACvB,QAAQ,EAAE,gFAAgF;IAC1F,OAAO,EAAE,CAAC,KAAK,EAAE,EAAE,CACjB,KAAK;SACF,UAAU,CAAC,MAAM,EAAE;QAClB,IAAI,EAAE,QAAQ;QACd,YAAY,EAAE,IAAI;QAClB,QAAQ,EAAE,kDAAkD;KAC7D,CAAC;SACD,MAAM,CAAC,MAAM,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,mBAAmB,EAAE,CAAC;SAChF,MAAM,CAAC,MAAM,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,qCAAqC,EAAE,CAAC;SACzG,MAAM,CAAC,SAAS,EAAE;QACjB,IAAI,EAAE,QAAQ;QACd,OAAO,EAAE,kBAAkB,CAAC,MAAM;QAClC,QAAQ,EAAE,oEAAoE;KAC/E,CAAC;SACD,MAAM,CAAC,wBAAwB,EAAE;QAChC,IAAI,EAAE,QAAQ;QACd,OAAO,EAAE,kBAAkB,CAAC,oBAAoB;QAChD,QAAQ,EAAE,0EAA0E;KACrF,CAAC;SACD,MAAM,CAAC,SAAS,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,6CAA6C,EAAE,CAAC;IACnH,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC;CACpC,CAAC,CAAC"}
@@ -0,0 +1,32 @@
1
+ export interface RawImage {
2
+ data: Buffer;
3
+ width: number;
4
+ height: number;
5
+ }
6
+ /** All errors are normalized to 0–1. `psnr` is in dB over RGB 0–255, and `null` when the images are identical (infinite PSNR). */
7
+ export interface ImageMetrics {
8
+ psnr: number | null;
9
+ rmse: number;
10
+ mae: number;
11
+ maxError: number;
12
+ }
13
+ /** Encoding for committed result images: near-lossless (worst render ~40 dB PSNR vs PNG), full-res chroma so noise and false colour survive. */
14
+ export declare const RESULT_AVIF: {
15
+ readonly quality: 90;
16
+ readonly chromaSubsampling: '4:4:4';
17
+ };
18
+ /** Decodes an image (path or encoded buffer) to 8-bit RGB, dropping alpha. */
19
+ export declare function readRgb(input: string | Buffer): Promise<RawImage>;
20
+ /** Computes metrics and an RGB heat-map delta image (black = identical) from two equal-size RGB images. */
21
+ export declare function compareRgb(reference: RawImage, test: RawImage): {
22
+ metrics: ImageMetrics;
23
+ delta: RawImage;
24
+ };
25
+ /** Compares two encoded images (paths or buffers); returns metrics and the delta encoded like the renders. */
26
+ export declare function compareImages(reference: string | Buffer, test: string | Buffer): Promise<{
27
+ metrics: ImageMetrics;
28
+ width: number;
29
+ height: number;
30
+ deltaImage: Buffer;
31
+ }>;
32
+ //# sourceMappingURL=compare.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compare.d.ts","sourceRoot":"","sources":["../../src/core/compare.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,kIAAkI;AAClI,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,gJAAgJ;AAChJ,eAAO,MAAM,WAAW;aAAK,OAAO,EAAE,EAAE;aAAE,iBAAiB,EAAE,OAAO;CAAW,CAAC;AAEhF,8EAA8E;AAC9E,wBAAsB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,CAOvE;AA0BD,2GAA2G;AAC3G,wBAAgB,UAAU,CAAC,SAAS,EAAE,QAAQ,EAAE,IAAI,EAAE,QAAQ,GAAG;IAAE,OAAO,EAAE,YAAY,CAAC;IAAC,KAAK,EAAE,QAAQ,CAAA;CAAE,CAmC1G;AAED,8GAA8G;AAC9G,wBAAsB,aAAa,CACjC,SAAS,EAAE,MAAM,GAAG,MAAM,EAC1B,IAAI,EAAE,MAAM,GAAG,MAAM,GACpB,OAAO,CAAC;IAAE,OAAO,EAAE,YAAY,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAAC,CAOvF"}
@@ -0,0 +1,81 @@
1
+ import sharp from 'sharp';
2
+ /** Encoding for committed result images: near-lossless (worst render ~40 dB PSNR vs PNG), full-res chroma so noise and false colour survive. */
3
+ export const RESULT_AVIF = { quality: 90, chromaSubsampling: '4:4:4' };
4
+ /** Decodes an image (path or encoded buffer) to 8-bit RGB, dropping alpha. */
5
+ export async function readRgb(input) {
6
+ const { data, info } = await sharp(input)
7
+ .removeAlpha()
8
+ .toColourspace('srgb')
9
+ .raw()
10
+ .toBuffer({ resolveWithObject: true });
11
+ return { data, width: info.width, height: info.height };
12
+ }
13
+ // Inferno colormap, polynomial fit by Matt Zucker (CC0). inferno(0) clamps to black.
14
+ const INFERNO = [
15
+ [0.0002189403691192265, 0.001651004631001012, -0.01948089843709184],
16
+ [0.1065134194856116, 0.5639564367884091, 3.932712388889277],
17
+ [11.60249308247187, -3.972853965665698, -15.9423941062914],
18
+ [-41.70399613139459, 17.43639888205313, 44.35414519872813],
19
+ [77.162935699427, -33.40235894210092, -81.80730925738993],
20
+ [-71.31942824499214, 32.62606426397723, 73.20951985803202],
21
+ [25.13112622477341, -12.24266895238567, -23.07032500287172],
22
+ ];
23
+ function inferno(t) {
24
+ const rgb = [0, 0, 0];
25
+ for (let c = 0; c < 3; c += 1) {
26
+ let v = 0;
27
+ for (let i = INFERNO.length - 1; i >= 0; i -= 1)
28
+ v = v * t + INFERNO[i][c];
29
+ rgb[c] = Math.round(Math.min(1, Math.max(0, v)) * 255);
30
+ }
31
+ return rgb;
32
+ }
33
+ // Max-channel error e (0–255) → t = min(1, 2·sqrt(e/255)): 1/255 error is already visible, ≥25% error saturates.
34
+ const DELTA_LUT = Array.from({ length: 256 }, (_, e) => inferno(Math.min(1, 2 * Math.sqrt(e / 255))));
35
+ /** Computes metrics and an RGB heat-map delta image (black = identical) from two equal-size RGB images. */
36
+ export function compareRgb(reference, test) {
37
+ if (reference.width !== test.width || reference.height !== test.height) {
38
+ throw new Error(`Image size mismatch: reference ${reference.width}x${reference.height} vs test ${test.width}x${test.height}`);
39
+ }
40
+ const n = reference.data.length;
41
+ const delta = Buffer.alloc(n);
42
+ let sumSq = 0;
43
+ let sumAbs = 0;
44
+ let max = 0;
45
+ for (let p = 0; p < n; p += 3) {
46
+ let pixelMax = 0;
47
+ for (let c = 0; c < 3; c += 1) {
48
+ const d = Math.abs(reference.data[p + c] - test.data[p + c]);
49
+ sumSq += d * d;
50
+ sumAbs += d;
51
+ if (d > pixelMax)
52
+ pixelMax = d;
53
+ }
54
+ if (pixelMax > max)
55
+ max = pixelMax;
56
+ const [r, g, b] = DELTA_LUT[pixelMax];
57
+ delta[p] = r;
58
+ delta[p + 1] = g;
59
+ delta[p + 2] = b;
60
+ }
61
+ const mse = sumSq / n;
62
+ return {
63
+ metrics: {
64
+ psnr: mse === 0 ? null : 10 * Math.log10((255 * 255) / mse),
65
+ rmse: Math.sqrt(mse) / 255,
66
+ mae: sumAbs / n / 255,
67
+ maxError: max / 255,
68
+ },
69
+ delta: { data: delta, width: reference.width, height: reference.height },
70
+ };
71
+ }
72
+ /** Compares two encoded images (paths or buffers); returns metrics and the delta encoded like the renders. */
73
+ export async function compareImages(reference, test) {
74
+ const [ref, tst] = await Promise.all([readRgb(reference), readRgb(test)]);
75
+ const { metrics, delta } = compareRgb(ref, tst);
76
+ const deltaImage = await sharp(delta.data, { raw: { width: delta.width, height: delta.height, channels: 3 } })
77
+ .avif(RESULT_AVIF)
78
+ .toBuffer();
79
+ return { metrics, width: ref.width, height: ref.height, deltaImage };
80
+ }
81
+ //# sourceMappingURL=compare.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compare.js","sourceRoot":"","sources":["../../src/core/compare.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,39 @@
1
+ import { type HashEntry } from './hash.js';
2
+ /**
3
+ * Lazy content-hash map keyed by absolute path: a file is hashed (streamed, CRC32) only when its mtime or size differs
4
+ * from the entry. Only the hash is kept, never the bytes, so memory stays flat however many images a suite has. Entries
5
+ * can be pre-populated from `image-hashes.json`; they are trusted only while the file's stat still matches.
6
+ */
7
+ export declare class HashStore {
8
+ private entries;
9
+ /** Concurrent first requests for the same file (a visitor scrolling a page of images) share one hash pass. */
10
+ private hashing;
11
+ /** Bumped whenever the set of known hashes changes; drives the ETag of the hash listing. */
12
+ version: number;
13
+ preload(path: string, entry: HashEntry): void;
14
+ entryFor(path: string, mtimeMs: number, size: number): Promise<HashEntry>;
15
+ /** Every hash that is valid right now: entries whose file changed or vanished are dropped. */
16
+ known(): Promise<Map<string, string>>;
17
+ }
18
+ /** Refuses paths that escape `root`. */
19
+ export declare function resolveInside(root: string, rel: string): string | null;
20
+ export declare const IMMUTABLE = "public, max-age=31536000, immutable";
21
+ /** True when the request's validators say the client's copy is current (If-None-Match wins over If-Modified-Since). */
22
+ export declare function notModified(req: Request, etag: string, mtimeMs: number): boolean;
23
+ /**
24
+ * Serves a file with CDN-friendly validators: strong ETag (`"<size>-<crc32>"`, the content hash), Last-Modified, Content-Length, nosniff, and
25
+ * 304 on If-None-Match / If-Modified-Since. The body is streamed from disk. `cacheControl` defaults to `no-cache`
26
+ * (always revalidate); `immutableIf` is a `?v=` version that becomes `immutableCacheControl` only if it equals the file's
27
+ * current hash (an unverified version keeps `cacheControl`). There is no Vary: the response never depends on request headers.
28
+ */
29
+ export declare function fileResponse(req: Request, path: string, opts?: {
30
+ cacheControl?: string;
31
+ immutableIf?: string | null;
32
+ store?: HashStore;
33
+ }): Promise<Response>;
34
+ /**
35
+ * Dev-mode variant: the file exactly as it is on disk right now, every time. No ETag, no Last-Modified, no hash cache,
36
+ * conditional headers are ignored (always 200) and `Cache-Control: no-store` keeps browsers and proxies from reusing it.
37
+ */
38
+ export declare function freshFileResponse(path: string): Promise<Response>;
39
+ //# sourceMappingURL=etag.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"etag.d.ts","sourceRoot":"","sources":["../../src/core/etag.ts"],"names":[],"mappings":"AAIA,OAAO,EAAY,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AAErD;;;;GAIG;AACH,qBAAa,SAAS;IACpB,OAAO,CAAC,OAAO,CAAgC;IAC/C,8GAA8G;IAC9G,OAAO,CAAC,OAAO,CAAyC;IACxD,4FAA4F;IAC5F,OAAO,SAAK;IAEZ,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,QAGrC;IAEK,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAe9E;IAED,8FAA8F;IACxF,KAAK,IAAI,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAW1C;CACF;AAKD,wCAAwC;AACxC,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAItE;AAED,eAAO,MAAM,SAAS,wCAAwC,CAAC;AAgB/D,uHAAuH;AACvH,wBAAgB,WAAW,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAKhF;AAED;;;;;GAKG;AACH,wBAAsB,YAAY,CAChC,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,MAAM,EACZ,IAAI,GAAE;IAAE,YAAY,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,KAAK,CAAC,EAAE,SAAS,CAAA;CAAO,GACnF,OAAO,CAAC,QAAQ,CAAC,CAgBnB;AAOD;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,CAMvE"}
@@ -0,0 +1,121 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { stat } from 'node:fs/promises';
3
+ import { resolve, sep } from 'node:path';
4
+ import { Readable } from 'node:stream';
5
+ import { hashFile } from './hash.js';
6
+ /**
7
+ * Lazy content-hash map keyed by absolute path: a file is hashed (streamed, CRC32) only when its mtime or size differs
8
+ * from the entry. Only the hash is kept, never the bytes, so memory stays flat however many images a suite has. Entries
9
+ * can be pre-populated from `image-hashes.json`; they are trusted only while the file's stat still matches.
10
+ */
11
+ export class HashStore {
12
+ entries = new Map();
13
+ /** Concurrent first requests for the same file (a visitor scrolling a page of images) share one hash pass. */
14
+ hashing = new Map();
15
+ /** Bumped whenever the set of known hashes changes; drives the ETag of the hash listing. */
16
+ version = 0;
17
+ preload(path, entry) {
18
+ this.entries.set(path, entry);
19
+ this.version++;
20
+ }
21
+ async entryFor(path, mtimeMs, size) {
22
+ const hit = this.entries.get(path);
23
+ if (hit && hit.mtimeMs === mtimeMs && hit.size === size)
24
+ return hit;
25
+ const key = `${path}\0${mtimeMs}\0${size}`;
26
+ let pending = this.hashing.get(key);
27
+ if (!pending) {
28
+ pending = (async () => {
29
+ const entry = { mtimeMs, size, hash: await hashFile(path) };
30
+ this.entries.set(path, entry);
31
+ this.version++;
32
+ return entry;
33
+ })().finally(() => this.hashing.delete(key));
34
+ this.hashing.set(key, pending);
35
+ }
36
+ return pending;
37
+ }
38
+ /** Every hash that is valid right now: entries whose file changed or vanished are dropped. */
39
+ async known() {
40
+ await Promise.all([...this.entries].map(async ([path, e]) => {
41
+ const s = await stat(path).catch(() => null);
42
+ if (!s?.isFile() || s.mtimeMs !== e.mtimeMs || s.size !== e.size) {
43
+ this.entries.delete(path);
44
+ this.version++;
45
+ }
46
+ }));
47
+ return new Map([...this.entries].map(([path, e]) => [path, e.hash]));
48
+ }
49
+ }
50
+ /** Used by `fileResponse` callers that don't bring their own store (the server has one per handler). */
51
+ const sharedStore = new HashStore();
52
+ /** Refuses paths that escape `root`. */
53
+ export function resolveInside(root, rel) {
54
+ const base = resolve(root);
55
+ const full = resolve(base, rel);
56
+ return full === base || full.startsWith(base + sep) ? full : null;
57
+ }
58
+ export const IMMUTABLE = 'public, max-age=31536000, immutable';
59
+ const TYPES = {
60
+ avif: 'image/avif',
61
+ png: 'image/png',
62
+ jpg: 'image/jpeg',
63
+ webp: 'image/webp',
64
+ json: 'application/json',
65
+ md: 'text/markdown; charset=utf-8',
66
+ html: 'text/html; charset=utf-8',
67
+ js: 'text/javascript; charset=utf-8',
68
+ css: 'text/css; charset=utf-8',
69
+ svg: 'image/svg+xml',
70
+ ico: 'image/x-icon',
71
+ };
72
+ /** True when the request's validators say the client's copy is current (If-None-Match wins over If-Modified-Since). */
73
+ export function notModified(req, etag, mtimeMs) {
74
+ const inm = req.headers.get('if-none-match');
75
+ if (inm)
76
+ return inm.trim() === '*' || inm.split(',').some((t) => t.trim().replace(/^W\//, '') === etag);
77
+ const ims = Date.parse(req.headers.get('if-modified-since') ?? '');
78
+ return Math.floor(mtimeMs / 1000) <= Math.floor(ims / 1000); // NaN (absent/invalid) compares false
79
+ }
80
+ /**
81
+ * Serves a file with CDN-friendly validators: strong ETag (`"<size>-<crc32>"`, the content hash), Last-Modified, Content-Length, nosniff, and
82
+ * 304 on If-None-Match / If-Modified-Since. The body is streamed from disk. `cacheControl` defaults to `no-cache`
83
+ * (always revalidate); `immutableIf` is a `?v=` version that becomes `immutableCacheControl` only if it equals the file's
84
+ * current hash (an unverified version keeps `cacheControl`). There is no Vary: the response never depends on request headers.
85
+ */
86
+ export async function fileResponse(req, path, opts = {}) {
87
+ const s = await stat(path).catch(() => null);
88
+ if (!s?.isFile())
89
+ return new Response('Not found', { status: 404, headers: { 'Cache-Control': 'no-cache' } });
90
+ const { hash } = await (opts.store ?? sharedStore).entryFor(path, s.mtimeMs, s.size);
91
+ const etag = `"${hash}"`;
92
+ const verified = opts.immutableIf === hash;
93
+ const headers = {
94
+ ETag: etag,
95
+ 'Last-Modified': new Date(s.mtimeMs).toUTCString(),
96
+ 'Cache-Control': verified ? IMMUTABLE : (opts.cacheControl ?? 'no-cache'),
97
+ 'X-Content-Type-Options': 'nosniff',
98
+ };
99
+ if (notModified(req, etag, s.mtimeMs))
100
+ return new Response(null, { status: 304, headers });
101
+ return new Response(Readable.toWeb(createReadStream(path)), {
102
+ headers: { ...headers, ...bodyHeaders(path, s.size) },
103
+ });
104
+ }
105
+ function bodyHeaders(path, size) {
106
+ const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase();
107
+ return { 'Content-Type': TYPES[ext] ?? 'application/octet-stream', 'Content-Length': String(size) };
108
+ }
109
+ /**
110
+ * Dev-mode variant: the file exactly as it is on disk right now, every time. No ETag, no Last-Modified, no hash cache,
111
+ * conditional headers are ignored (always 200) and `Cache-Control: no-store` keeps browsers and proxies from reusing it.
112
+ */
113
+ export async function freshFileResponse(path) {
114
+ const s = await stat(path).catch(() => null);
115
+ if (!s?.isFile())
116
+ return new Response('Not found', { status: 404, headers: { 'Cache-Control': 'no-store' } });
117
+ return new Response(Readable.toWeb(createReadStream(path)), {
118
+ headers: { ...bodyHeaders(path, s.size), 'Cache-Control': 'no-store' },
119
+ });
120
+ }
121
+ //# sourceMappingURL=etag.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"etag.js","sourceRoot":"","sources":["../../src/core/etag.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ export declare const HASHES_FILE = "image-hashes.json";
2
+ export interface HashEntry {
3
+ hash: string;
4
+ size: number;
5
+ mtimeMs: number;
6
+ }
7
+ /**
8
+ * Content hash shared by the ETag and the `?v=` URL version: `<size>-<crc32>` in base36. Streamed, so memory stays flat
9
+ * however large the file is. Fast and non-cryptographic: it detects change, it is not a security boundary.
10
+ */
11
+ export declare function hashFile(path: string): Promise<string>;
12
+ /** The entries of `<root>/image-hashes.json`; empty when it is missing or malformed (they are only a cache). */
13
+ export declare function readHashFile(root: string): Promise<Record<string, HashEntry>>;
14
+ export interface HashSuiteOptions {
15
+ /** Parallel file reads; defaults to `os.availableParallelism()`. */
16
+ concurrency?: number;
17
+ /** Injectable for tests. */
18
+ hash?: (path: string) => Promise<string>;
19
+ }
20
+ export interface HashSuiteResult {
21
+ hashed: number;
22
+ reused: number;
23
+ total: number;
24
+ }
25
+ /**
26
+ * Writes `<root>/image-hashes.json` for every served `.avif`. Incremental: an entry whose size and mtimeMs are unchanged
27
+ * is reused without reading the file. Output is sorted and timestamp-free, so unchanged files give identical bytes.
28
+ */
29
+ export declare function hashSuite(root: string, opts?: HashSuiteOptions): Promise<HashSuiteResult>;
30
+ //# sourceMappingURL=hash.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hash.d.ts","sourceRoot":"","sources":["../../src/core/hash.ts"],"names":[],"mappings":"AASA,eAAO,MAAM,WAAW,sBAAsB,CAAC;AAE/C,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAOD;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAQ5D;AAED,gHAAgH;AAChH,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAMnF;AAED,MAAM,WAAW,gBAAgB;IAC/B,oEAAoE;IACpE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,4BAA4B;IAC5B,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CAC1C;AACD,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;GAGG;AACH,wBAAsB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,gBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC,CA4BnG"}
@@ -0,0 +1,65 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { readFile, readdir, rename, stat, writeFile } from 'node:fs/promises';
3
+ import { availableParallelism } from 'node:os';
4
+ import { join, resolve } from 'node:path';
5
+ import { crc32 } from 'node:zlib';
6
+ import pLimit from 'p-limit';
7
+ import { z } from 'zod';
8
+ import { isDataFile } from './paths.js';
9
+ export const HASHES_FILE = 'image-hashes.json';
10
+ const fileSchema = z.object({
11
+ version: z.literal(1),
12
+ files: z.record(z.string(), z.object({ hash: z.string(), size: z.number(), mtimeMs: z.number() })),
13
+ });
14
+ /**
15
+ * Content hash shared by the ETag and the `?v=` URL version: `<size>-<crc32>` in base36. Streamed, so memory stays flat
16
+ * however large the file is. Fast and non-cryptographic: it detects change, it is not a security boundary.
17
+ */
18
+ export async function hashFile(path) {
19
+ let crc = 0;
20
+ let size = 0;
21
+ for await (const chunk of createReadStream(path)) {
22
+ crc = crc32(chunk, crc);
23
+ size += chunk.length;
24
+ }
25
+ return `${size.toString(36)}-${crc.toString(36)}`;
26
+ }
27
+ /** The entries of `<root>/image-hashes.json`; empty when it is missing or malformed (they are only a cache). */
28
+ export async function readHashFile(root) {
29
+ try {
30
+ return fileSchema.parse(JSON.parse(await readFile(join(root, HASHES_FILE), 'utf8'))).files;
31
+ }
32
+ catch {
33
+ return {};
34
+ }
35
+ }
36
+ /**
37
+ * Writes `<root>/image-hashes.json` for every served `.avif`. Incremental: an entry whose size and mtimeMs are unchanged
38
+ * is reused without reading the file. Output is sorted and timestamp-free, so unchanged files give identical bytes.
39
+ */
40
+ export async function hashSuite(root, opts = {}) {
41
+ root = resolve(root);
42
+ const { concurrency = availableParallelism(), hash = hashFile } = opts;
43
+ const limit = pLimit(Math.max(1, Math.floor(concurrency)));
44
+ const previous = await readHashFile(root);
45
+ const rels = (await readdir(root, { recursive: true }))
46
+ .map((p) => p.split('\\').join('/'))
47
+ .filter((rel) => rel.endsWith('.avif') && isDataFile(rel))
48
+ .toSorted();
49
+ let reused = 0;
50
+ const entries = await Promise.all(rels.map((rel) => limit(async () => {
51
+ const s = await stat(join(root, rel));
52
+ const old = previous[rel];
53
+ if (old && old.size === s.size && old.mtimeMs === s.mtimeMs) {
54
+ reused++;
55
+ return [rel, old];
56
+ }
57
+ return [rel, { hash: await hash(join(root, rel)), size: s.size, mtimeMs: s.mtimeMs }];
58
+ })));
59
+ const out = join(root, HASHES_FILE);
60
+ const tmp = `${out}.${process.pid}.tmp`;
61
+ await writeFile(tmp, `${JSON.stringify({ version: 1, files: Object.fromEntries(entries) }, null, 2)}\n`);
62
+ await rename(tmp, out);
63
+ return { hashed: entries.length - reused, reused, total: entries.length };
64
+ }
65
+ //# sourceMappingURL=hash.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hash.js","sourceRoot":"","sources":["../../src/core/hash.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=hash.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hash.test.d.ts","sourceRoot":"","sources":["../../src/core/hash.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,71 @@
1
+ import { mkdir, mkdtemp, readFile, utimes, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { expect, test } from 'vitest';
5
+ import { HASHES_FILE, hashFile, hashSuite } from './index.js';
6
+ async function suite() {
7
+ const root = await mkdtemp(join(tmpdir(), 'fk-hash-'));
8
+ await mkdir(join(root, 'b', 'beauty'), { recursive: true });
9
+ await mkdir(join(root, 'a', 'beauty'), { recursive: true });
10
+ await mkdir(join(root, '.hidden'), { recursive: true });
11
+ for (const [f, c] of [
12
+ ['b/beauty/x.avif', 'bbb'],
13
+ ['a/beauty/x.avif', 'a'],
14
+ ['a/beauty/y.avif', 'aaaa'],
15
+ ['a/notes.txt', 'ignored'],
16
+ ['.hidden/z.avif', 'ignored'],
17
+ ]) {
18
+ await writeFile(join(root, f), c);
19
+ }
20
+ return root;
21
+ }
22
+ const read = async (root) => JSON.parse(await readFile(join(root, HASHES_FILE), 'utf8'));
23
+ test('hash is deterministic, sorted, covers only served images, and is incremental', async () => {
24
+ const root = await suite();
25
+ expect(await hashSuite(root)).toEqual({ hashed: 3, reused: 0, total: 3 });
26
+ const first = await readFile(join(root, HASHES_FILE), 'utf8');
27
+ const json = JSON.parse(first);
28
+ expect(json.version).toBe(1);
29
+ expect(Object.keys(json.files)).toEqual(['a/beauty/x.avif', 'a/beauty/y.avif', 'b/beauty/x.avif']);
30
+ expect(json.files['b/beauty/x.avif']).toMatchObject({ hash: await hashFile(join(root, 'b/beauty/x.avif')), size: 3 });
31
+ expect(await hashSuite(root)).toEqual({ hashed: 0, reused: 3, total: 3 });
32
+ expect(await readFile(join(root, HASHES_FILE), 'utf8')).toBe(first);
33
+ // changed content (new size) is rehashed, the rest is reused without reading
34
+ const hashed = [];
35
+ await writeFile(join(root, 'a/beauty/y.avif'), 'changed!');
36
+ const r = await hashSuite(root, {
37
+ hash: (p) => {
38
+ hashed.push(p);
39
+ return hashFile(p);
40
+ },
41
+ });
42
+ expect(r).toEqual({ hashed: 1, reused: 2, total: 3 });
43
+ expect(hashed).toEqual([join(root, 'a/beauty/y.avif')]);
44
+ expect((await read(root)).files['a/beauty/y.avif'].hash).not.toBe(json.files['a/beauty/y.avif'].hash);
45
+ // a touched file (same bytes, new mtime) is rehashed to the same hash
46
+ const later = new Date(Date.now() + 10_000);
47
+ await utimes(join(root, 'a/beauty/x.avif'), later, later);
48
+ expect(await hashSuite(root)).toMatchObject({ hashed: 1, reused: 2 });
49
+ expect((await read(root)).files['a/beauty/x.avif'].hash).toBe(json.files['a/beauty/x.avif'].hash);
50
+ });
51
+ test('hash concurrency never exceeds the limit', async () => {
52
+ const root = await mkdtemp(join(tmpdir(), 'fk-hash-'));
53
+ await mkdir(join(root, 's', 'o'), { recursive: true });
54
+ for (let i = 0; i < 12; i++)
55
+ await writeFile(join(root, 's', 'o', `${i}.avif`), String(i));
56
+ let active = 0;
57
+ let peak = 0;
58
+ const hash = async () => {
59
+ peak = Math.max(peak, ++active);
60
+ await new Promise((r) => setTimeout(r, 5));
61
+ active--;
62
+ return 'h';
63
+ };
64
+ await hashSuite(root, { concurrency: 3, hash });
65
+ expect(peak).toBe(3);
66
+ peak = 0;
67
+ await writeFile(join(root, HASHES_FILE), '{}'); // malformed cache is ignored
68
+ await hashSuite(root, { concurrency: 1, hash });
69
+ expect(peak).toBe(1);
70
+ });
71
+ //# sourceMappingURL=hash.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hash.test.js","sourceRoot":"","sources":["../../src/core/hash.test.ts"],"names":[],"mappings":"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"}