fundus 0.29.0 → 0.29.2

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 (171) hide show
  1. package/README.md +6 -7
  2. package/build/client/_app/immutable/chunks/BHq5MS2C.js +121 -0
  3. package/build/client/_app/immutable/chunks/BHq5MS2C.js.br +0 -0
  4. package/build/client/_app/immutable/chunks/BHq5MS2C.js.gz +0 -0
  5. package/build/client/_app/immutable/chunks/{kkVEh3_1.js → BqkXxBeq.js} +1 -1
  6. package/build/client/_app/immutable/chunks/BqkXxBeq.js.br +1 -0
  7. package/build/client/_app/immutable/chunks/BqkXxBeq.js.gz +0 -0
  8. package/build/client/_app/immutable/chunks/{wBLhaPea.js → DX2oUnVm.js} +1 -1
  9. package/build/client/_app/immutable/chunks/DX2oUnVm.js.br +0 -0
  10. package/build/client/_app/immutable/chunks/DX2oUnVm.js.gz +0 -0
  11. package/build/client/_app/immutable/entry/{app.DCXSqiQ1.js → app.BQGsntjn.js} +2 -2
  12. package/build/client/_app/immutable/entry/app.BQGsntjn.js.br +0 -0
  13. package/build/client/_app/immutable/entry/app.BQGsntjn.js.gz +0 -0
  14. package/build/client/_app/immutable/entry/start.CaLHYp7U.js +1 -0
  15. package/build/client/_app/immutable/entry/start.CaLHYp7U.js.br +1 -0
  16. package/build/client/_app/immutable/entry/start.CaLHYp7U.js.gz +0 -0
  17. package/build/client/_app/immutable/nodes/{0.CsqkK05g.js → 0.Dp-23Tj-.js} +1 -1
  18. package/build/client/_app/immutable/nodes/0.Dp-23Tj-.js.br +0 -0
  19. package/build/client/_app/immutable/nodes/0.Dp-23Tj-.js.gz +0 -0
  20. package/build/client/_app/immutable/nodes/{1.BVSXvtU9.js → 1.DnbM8Ssn.js} +1 -1
  21. package/build/client/_app/immutable/nodes/1.DnbM8Ssn.js.br +1 -0
  22. package/build/client/_app/immutable/nodes/1.DnbM8Ssn.js.gz +0 -0
  23. package/build/client/_app/immutable/nodes/{2.BJEXWmrT.js → 2.Rrfk73B9.js} +1 -1
  24. package/build/client/_app/immutable/nodes/2.Rrfk73B9.js.br +0 -0
  25. package/build/client/_app/immutable/nodes/2.Rrfk73B9.js.gz +0 -0
  26. package/build/client/_app/immutable/nodes/{3.BDMmDyL1.js → 3.iY9Cfbve.js} +1 -1
  27. package/build/client/_app/immutable/nodes/3.iY9Cfbve.js.br +0 -0
  28. package/build/client/_app/immutable/nodes/3.iY9Cfbve.js.gz +0 -0
  29. package/build/client/_app/immutable/nodes/{4.dd49_qQE.js → 4.Gy5flGJv.js} +1 -1
  30. package/build/client/_app/immutable/nodes/4.Gy5flGJv.js.br +0 -0
  31. package/build/client/_app/immutable/nodes/4.Gy5flGJv.js.gz +0 -0
  32. package/build/client/_app/version.json +1 -1
  33. package/build/client/_app/version.json.br +0 -0
  34. package/build/client/_app/version.json.gz +0 -0
  35. package/build/handler.js +7 -7
  36. package/build/index.js +7 -7
  37. package/build/server/chunks/chunks/{context.js-DFZgDxCD.js → context.js-BR2IRl9z.js} +52 -24
  38. package/build/server/chunks/chunks/context.js-BR2IRl9z.js.map +1 -0
  39. package/build/server/chunks/chunks/{contract.js-DGZXpcNX.js → contract.js-DHeIVVv8.js} +44 -10
  40. package/build/server/chunks/chunks/contract.js-DHeIVVv8.js.map +1 -0
  41. package/build/server/chunks/chunks/{exports.js-BsVKSGJk.js → exports.js--TjIdS7-.js} +2 -2
  42. package/build/server/chunks/chunks/{exports.js-BsVKSGJk.js.map → exports.js--TjIdS7-.js.map} +1 -1
  43. package/build/server/chunks/chunks/{http.js-BQhjMaW9.js → http.js-dk5jbOFm.js} +2 -2
  44. package/build/server/chunks/chunks/{http.js-BQhjMaW9.js.map → http.js-dk5jbOFm.js.map} +1 -1
  45. package/build/server/chunks/chunks/{internal.js-DJGcNryj.js → internal.js-0bg1jWM6.js} +3 -3
  46. package/build/server/chunks/chunks/{internal.js-DJGcNryj.js.map → internal.js-0bg1jWM6.js.map} +1 -1
  47. package/build/server/chunks/chunks/internal2.js-BlXt2rfE.js.map +1 -1
  48. package/build/server/chunks/chunks/{raw-reconciliation2.js-CMpUvuMd.js → raw-reconciliation.js-Cco4r8lr.js} +51 -17
  49. package/build/server/chunks/chunks/raw-reconciliation.js-Cco4r8lr.js.map +1 -0
  50. package/build/server/chunks/chunks/{raw-reconciliation.js-D2sI2C-t.js → raw-reconciliation2.js-CuYSsYTP.js} +2 -2
  51. package/build/server/chunks/chunks/raw-reconciliation2.js-CuYSsYTP.js.map +1 -0
  52. package/build/server/chunks/chunks/{shared.js-BtmYg0tL.js → shared.js-xTT2CxAF.js} +2 -2
  53. package/build/server/chunks/chunks/{shared.js-BtmYg0tL.js.map → shared.js-xTT2CxAF.js.map} +1 -1
  54. package/build/server/chunks/chunks/{utils.js-CqXk0Ug6.js → utils.js-BsAVB8Bp.js} +3 -3
  55. package/build/server/chunks/chunks/{utils.js-CqXk0Ug6.js.map → utils.js-BsAVB8Bp.js.map} +1 -1
  56. package/build/server/chunks/entries/endpoints/api/_...path_/{_server.ts.js-DKGD9Jk2.js → _server.ts.js-8jGLP2Aj.js} +4 -4
  57. package/build/server/chunks/entries/endpoints/api/_...path_/{_server.ts.js-DKGD9Jk2.js.map → _server.ts.js-8jGLP2Aj.js.map} +1 -1
  58. package/build/server/chunks/entries/endpoints/api/assets/_id_/{_server.ts.js-C1UfF0EO.js → _server.ts.js-BOW9Ejim.js} +5 -5
  59. package/build/server/chunks/entries/endpoints/api/assets/_id_/{_server.ts.js-C1UfF0EO.js.map → _server.ts.js-BOW9Ejim.js.map} +1 -1
  60. package/build/server/chunks/entries/endpoints/api/assets/_id_/raw/{_server.ts.js-DpASkk00.js → _server.ts.js-Clac8atJ.js} +5 -5
  61. package/build/server/chunks/entries/endpoints/api/assets/_id_/raw/{_server.ts.js-DpASkk00.js.map → _server.ts.js-Clac8atJ.js.map} +1 -1
  62. package/build/server/chunks/entries/endpoints/api/assets/_id_/reimport/{_server.ts.js-C3jU2vC8.js → _server.ts.js-EdHTA-SO.js} +5 -5
  63. package/build/server/chunks/entries/endpoints/api/assets/_id_/reimport/{_server.ts.js-C3jU2vC8.js.map → _server.ts.js-EdHTA-SO.js.map} +1 -1
  64. package/build/server/chunks/entries/endpoints/api/assets/_id_/retag/{_server.ts.js-B7OwufF3.js → _server.ts.js-ZBCRgRYi.js} +5 -5
  65. package/build/server/chunks/entries/endpoints/api/assets/_id_/retag/{_server.ts.js-B7OwufF3.js.map → _server.ts.js-ZBCRgRYi.js.map} +1 -1
  66. package/build/server/chunks/entries/endpoints/api/assets/{_server.ts.js-COhg1nYk.js → _server.ts.js-DY_e5K6d.js} +5 -5
  67. package/build/server/chunks/entries/endpoints/api/assets/{_server.ts.js-COhg1nYk.js.map → _server.ts.js-DY_e5K6d.js.map} +1 -1
  68. package/build/server/chunks/entries/endpoints/api/assets/delete/{_server.ts.js-DAEE17eM.js → _server.ts.js-Cl2jxJNi.js} +5 -5
  69. package/build/server/chunks/entries/endpoints/api/assets/delete/{_server.ts.js-DAEE17eM.js.map → _server.ts.js-Cl2jxJNi.js.map} +1 -1
  70. package/build/server/chunks/entries/endpoints/api/assets/move/{_server.ts.js-BrZSS4gm.js → _server.ts.js-DrrZWUbd.js} +5 -5
  71. package/build/server/chunks/entries/endpoints/api/assets/move/{_server.ts.js-BrZSS4gm.js.map → _server.ts.js-DrrZWUbd.js.map} +1 -1
  72. package/build/server/chunks/entries/endpoints/api/events/{_server.ts.js-Cfst-Brd.js → _server.ts.js-Bnw0HbEk.js} +5 -5
  73. package/build/server/chunks/entries/endpoints/api/events/{_server.ts.js-Cfst-Brd.js.map → _server.ts.js-Bnw0HbEk.js.map} +1 -1
  74. package/build/server/chunks/entries/endpoints/api/folders/_path_/{_server.ts.js-aN_h2Ifo.js → _server.ts.js-NeVn2ap9.js} +5 -5
  75. package/build/server/chunks/entries/endpoints/api/folders/_path_/{_server.ts.js-aN_h2Ifo.js.map → _server.ts.js-NeVn2ap9.js.map} +1 -1
  76. package/build/server/chunks/entries/endpoints/api/folders/{_server.ts.js-CX2A66ij.js → _server.ts.js-ENGDgoLy.js} +5 -5
  77. package/build/server/chunks/entries/endpoints/api/folders/{_server.ts.js-CX2A66ij.js.map → _server.ts.js-ENGDgoLy.js.map} +1 -1
  78. package/build/server/chunks/entries/endpoints/api/imports/{_server.ts.js-CwTiaheP.js → _server.ts.js-BQVGCzBR.js} +5 -5
  79. package/build/server/chunks/entries/endpoints/api/imports/{_server.ts.js-CwTiaheP.js.map → _server.ts.js-BQVGCzBR.js.map} +1 -1
  80. package/build/server/chunks/entries/endpoints/api/manifests/_name_/{_server.ts.js-fIbsr_2e.js → _server.ts.js-Pxver0Y-.js} +5 -5
  81. package/build/server/chunks/entries/endpoints/api/manifests/_name_/{_server.ts.js-fIbsr_2e.js.map → _server.ts.js-Pxver0Y-.js.map} +1 -1
  82. package/build/server/chunks/entries/endpoints/api/manifests/{_server.ts.js-Dvo1bsLi.js → _server.ts.js-DYtiqzJq.js} +5 -5
  83. package/build/server/chunks/entries/endpoints/api/manifests/{_server.ts.js-Dvo1bsLi.js.map → _server.ts.js-DYtiqzJq.js.map} +1 -1
  84. package/build/server/chunks/entries/endpoints/api/raw/adopt/{_server.ts.js-VLg1G3mN.js → _server.ts.js-lBgmEZHV.js} +5 -5
  85. package/build/server/chunks/entries/endpoints/api/raw/adopt/{_server.ts.js-VLg1G3mN.js.map → _server.ts.js-lBgmEZHV.js.map} +1 -1
  86. package/build/server/chunks/entries/endpoints/api/raw/edited/accept/{_server.ts.js-DqctsOop.js → _server.ts.js-BJHyONFH.js} +5 -5
  87. package/build/server/chunks/entries/endpoints/api/raw/edited/accept/{_server.ts.js-DqctsOop.js.map → _server.ts.js-BJHyONFH.js.map} +1 -1
  88. package/build/server/chunks/entries/endpoints/api/raw/edited/dismiss/{_server.ts.js-CoxSU5iO.js → _server.ts.js-Bm5XO80W.js} +5 -5
  89. package/build/server/chunks/entries/endpoints/api/raw/edited/dismiss/{_server.ts.js-CoxSU5iO.js.map → _server.ts.js-Bm5XO80W.js.map} +1 -1
  90. package/build/server/chunks/entries/endpoints/api/reconcile/{_server.ts.js-wUxsLZdr.js → _server.ts.js-B7Z20kYf.js} +5 -5
  91. package/build/server/chunks/entries/endpoints/api/reconcile/{_server.ts.js-wUxsLZdr.js.map → _server.ts.js-B7Z20kYf.js.map} +1 -1
  92. package/build/server/chunks/entries/endpoints/api/reveal/{_server.ts.js-BKsF0Oit.js → _server.ts.js-Cg-f37jc.js} +5 -5
  93. package/build/server/chunks/entries/endpoints/api/reveal/{_server.ts.js-BKsF0Oit.js.map → _server.ts.js-Cg-f37jc.js.map} +1 -1
  94. package/build/server/chunks/entries/endpoints/api/state/{_server.ts.js-Dk9oOZwM.js → _server.ts.js-dIXZiq5l.js} +5 -5
  95. package/build/server/chunks/entries/endpoints/api/state/{_server.ts.js-Dk9oOZwM.js.map → _server.ts.js-dIXZiq5l.js.map} +1 -1
  96. package/build/server/chunks/entries/endpoints/api/usage/{_server.ts.js-DxjNmmtL.js → _server.ts.js-CnTrS5pT.js} +5 -5
  97. package/build/server/chunks/entries/endpoints/api/usage/{_server.ts.js-DxjNmmtL.js.map → _server.ts.js-CnTrS5pT.js.map} +1 -1
  98. package/build/server/chunks/entries/endpoints/files/_...path_/{_server.ts.js-DD0b38VV.js → _server.ts.js-B7TcFRuL.js} +4 -4
  99. package/build/server/chunks/entries/endpoints/files/_...path_/{_server.ts.js-DD0b38VV.js.map → _server.ts.js-B7TcFRuL.js.map} +1 -1
  100. package/build/server/chunks/entries/endpoints/files/proxy/_...path_/{_server.ts.js-DId6F2i_.js → _server.ts.js-BkUQInqW.js} +5 -5
  101. package/build/server/chunks/entries/endpoints/files/proxy/_...path_/{_server.ts.js-DId6F2i_.js.map → _server.ts.js-BkUQInqW.js.map} +1 -1
  102. package/build/server/chunks/entries/endpoints/files/raw/_...path_/{_server.ts.js-DKKkGoK2.js → _server.ts.js-CshyvqOq.js} +5 -5
  103. package/build/server/chunks/entries/endpoints/files/raw/_...path_/{_server.ts.js-DKKkGoK2.js.map → _server.ts.js-CshyvqOq.js.map} +1 -1
  104. package/build/server/chunks/entries/{hooks.server.js-B5Ctvorj.js → hooks.server.js-D4DECjz_.js} +6 -5
  105. package/build/server/chunks/entries/hooks.server.js-D4DECjz_.js.map +1 -0
  106. package/build/server/chunks/{handler-DwVfBx6r.js → handler-DJLsE3cc.js} +5 -5
  107. package/build/server/chunks/{handler-DwVfBx6r.js.map → handler-DJLsE3cc.js.map} +1 -1
  108. package/build/server/chunks/{index.js-DgPuPQJn.js → index.js-BYjtKmHQ.js} +5 -5
  109. package/build/server/chunks/{index.js-DgPuPQJn.js.map → index.js-BYjtKmHQ.js.map} +1 -1
  110. package/build/server/chunks/{manifest.js-B054EcAf.js → manifest.js-CUqPgamt.js} +31 -31
  111. package/build/server/chunks/{manifest.js-B054EcAf.js.map → manifest.js-CUqPgamt.js.map} +1 -1
  112. package/build/server/chunks/nodes/{0.js-DoHZTmnT.js → 0.js-DzXXdTj5.js} +2 -2
  113. package/build/server/chunks/nodes/{0.js-DoHZTmnT.js.map → 0.js-DzXXdTj5.js.map} +1 -1
  114. package/build/server/chunks/nodes/1.js-1XyvWIrN.js +7 -0
  115. package/build/server/chunks/nodes/{1.js-BcPlsloO.js.map → 1.js-1XyvWIrN.js.map} +1 -1
  116. package/build/server/chunks/nodes/{2.js-5qCXkvzl.js → 2.js-LV4HWUXV.js} +2 -2
  117. package/build/server/chunks/nodes/{2.js-5qCXkvzl.js.map → 2.js-LV4HWUXV.js.map} +1 -1
  118. package/build/server/chunks/nodes/3.js-CcUSX9ZF.js +7 -0
  119. package/build/server/chunks/nodes/{3.js-D6fAMX1w.js.map → 3.js-CcUSX9ZF.js.map} +1 -1
  120. package/build/server/chunks/nodes/{4.js-CGkeT0SQ.js → 4.js-D4nLILsU.js} +2 -2
  121. package/build/server/chunks/nodes/{4.js-CGkeT0SQ.js.map → 4.js-D4nLILsU.js.map} +1 -1
  122. package/dist/runtime/canvas.d.ts +1 -1
  123. package/dist/runtime/components/Slice.svelte +12 -16
  124. package/dist/runtime/kind-handlers.d.ts +6 -10
  125. package/dist/runtime/kind-handlers.js +29 -49
  126. package/dist/runtime/manifest.d.ts +2 -8
  127. package/dist/runtime/manifest.js +4 -5
  128. package/dist/runtime/preload-registry.d.ts +5 -7
  129. package/dist/runtime/preload-registry.js +6 -9
  130. package/dist/runtime/retained-entry.d.ts +1 -1
  131. package/dist/runtime/retained-entry.js +1 -1
  132. package/dist/runtime/slice-source-lifecycle.d.ts +2 -10
  133. package/dist/runtime/slice-source-lifecycle.js +2 -11
  134. package/dist/server/concurrency.d.ts +12 -0
  135. package/dist/server/concurrency.js +23 -0
  136. package/dist/server/image-duplicates.js +6 -4
  137. package/dist/server/operations.d.ts +7 -0
  138. package/dist/server/operations.js +53 -26
  139. package/dist/server/processing/processor.d.ts +29 -1
  140. package/dist/server/processing/processor.js +42 -5
  141. package/dist/server/raw-reconciliation.js +26 -10
  142. package/package.json +1 -1
  143. package/build/client/_app/immutable/chunks/BCmR1_e0.js +0 -121
  144. package/build/client/_app/immutable/chunks/BCmR1_e0.js.br +0 -0
  145. package/build/client/_app/immutable/chunks/BCmR1_e0.js.gz +0 -0
  146. package/build/client/_app/immutable/chunks/kkVEh3_1.js.br +0 -2
  147. package/build/client/_app/immutable/chunks/kkVEh3_1.js.gz +0 -0
  148. package/build/client/_app/immutable/chunks/wBLhaPea.js.br +0 -0
  149. package/build/client/_app/immutable/chunks/wBLhaPea.js.gz +0 -0
  150. package/build/client/_app/immutable/entry/app.DCXSqiQ1.js.br +0 -0
  151. package/build/client/_app/immutable/entry/app.DCXSqiQ1.js.gz +0 -0
  152. package/build/client/_app/immutable/entry/start.BOUpxuMU.js +0 -1
  153. package/build/client/_app/immutable/entry/start.BOUpxuMU.js.br +0 -0
  154. package/build/client/_app/immutable/entry/start.BOUpxuMU.js.gz +0 -0
  155. package/build/client/_app/immutable/nodes/0.CsqkK05g.js.br +0 -0
  156. package/build/client/_app/immutable/nodes/0.CsqkK05g.js.gz +0 -0
  157. package/build/client/_app/immutable/nodes/1.BVSXvtU9.js.br +0 -2
  158. package/build/client/_app/immutable/nodes/1.BVSXvtU9.js.gz +0 -0
  159. package/build/client/_app/immutable/nodes/2.BJEXWmrT.js.br +0 -0
  160. package/build/client/_app/immutable/nodes/2.BJEXWmrT.js.gz +0 -0
  161. package/build/client/_app/immutable/nodes/3.BDMmDyL1.js.br +0 -0
  162. package/build/client/_app/immutable/nodes/3.BDMmDyL1.js.gz +0 -0
  163. package/build/client/_app/immutable/nodes/4.dd49_qQE.js.br +0 -0
  164. package/build/client/_app/immutable/nodes/4.dd49_qQE.js.gz +0 -0
  165. package/build/server/chunks/chunks/context.js-DFZgDxCD.js.map +0 -1
  166. package/build/server/chunks/chunks/contract.js-DGZXpcNX.js.map +0 -1
  167. package/build/server/chunks/chunks/raw-reconciliation.js-D2sI2C-t.js.map +0 -1
  168. package/build/server/chunks/chunks/raw-reconciliation2.js-CMpUvuMd.js.map +0 -1
  169. package/build/server/chunks/entries/hooks.server.js-B5Ctvorj.js.map +0 -1
  170. package/build/server/chunks/nodes/1.js-BcPlsloO.js +0 -7
  171. package/build/server/chunks/nodes/3.js-D6fAMX1w.js +0 -7
@@ -1,3 +1,17 @@
1
+ /**
2
+ * The runtime part of the type-plugin contract: per-kind preload
3
+ * strategy and its inverse release strategy. Each first-party kind registers
4
+ * a handler that knows how its delivered file is loaded — images fetch and
5
+ * retain a decoded element, slices too, media kinds (video/audio) fetch
6
+ * fully and mint an object URL — and how what it loaded is disposed (revoke
7
+ * the object URL; decoded elements have nothing to dispose). An unknown kind
8
+ * falls back to a plain full fetch: no decode, no retention.
9
+ *
10
+ * Handlers are pure strategy: refcounting, cross-holder dedup, and
11
+ * in-flight bookkeeping live in the module-wide registry
12
+ * (preload-registry.ts); kind-agnostic orchestration lives in `Manifest` and
13
+ * `retainEntry`.
14
+ */
1
15
  import { plugins } from '../core/plugins/registry.js';
2
16
  /**
3
17
  * Entry kinds whose delivered file is retained as an object URL by
@@ -46,13 +60,21 @@ function verifyByteSize(url, expected, actual) {
46
60
  }
47
61
  }
48
62
  /**
49
- * The image strategy: fetch the full file, then decode it via an
63
+ * The image and slice strategy: fetch the full file, then decode it via an
50
64
  * HTMLImageElement where a DOM exists (the browser serves the bytes from
51
- * cache); headless environments (Node tests, SSR) skip decoding.
52
- * Retains the decoded element for canvas drawing until the last holder releases.
53
- * Nothing to explicitly dispose — release drops the registry's reference.
65
+ * cache); headless environments (Node tests, SSR) skip decoding. Retains the
66
+ * decoded element until the last holder releases, so canvas drawing and
67
+ * `SliceSourceLifecycle` commit synchronously — a preloaded slice paints in
68
+ * its mount frame — and a one-slice's mounted `<img>` paints from a warm
69
+ * cache. Nothing to explicitly dispose — release drops the registry's
70
+ * reference.
71
+ *
72
+ * Deliberately not an ImageBitmap: WebKit on iOS can empty a bitmap while the
73
+ * app is in the background (memory pressure, GPU process restart) yet keep
74
+ * its size, so every later draw or texture upload from it is silently blank.
75
+ * An element whose decoded pixels were purged is decoded again from its file.
54
76
  */
55
- const imageHandler = {
77
+ const decodedImageHandler = {
56
78
  async preload(url, byteSize) {
57
79
  const response = await fetchCompletely(url);
58
80
  // Read the body fully so the file is actually transferred (and
@@ -68,48 +90,6 @@ const imageHandler = {
68
90
  return {};
69
91
  }
70
92
  };
71
- /**
72
- * The slice strategy: fetch the full file and retain a decoded form while
73
- * held, chosen by the entry's slicing mode. Multi-cell slices (nine,
74
- * three-*) draw through a canvas and retain the pixels as an ImageBitmap —
75
- * unlike a throwaway decode into the browser's evictable image cache, the
76
- * bitmap survives memory pressure and lets `SliceSourceLifecycle` commit
77
- * synchronously, so a preloaded slice paints in its mount frame. One-slice
78
- * sources render as a plain `<img>`, which cannot paint from a bitmap: they
79
- * retain the decoded HTMLImageElement instead — the same synchronous commit,
80
- * and its decode warms the (evictable) cache the mounted `<img>` paints
81
- * from. Release closes the bitmap; a retained element has nothing to
82
- * dispose. Slice sources are small (the insets cover every rendered size),
83
- * so retention is cheap. Environments without `createImageBitmap` retain the
84
- * decoded element for every mode.
85
- *
86
- * Bitmaps are premultiplied so WebGL uploads match the premultiplied texture a
87
- * retained element yields with `UNPACK_PREMULTIPLY_ALPHA_WEBGL` (bitmaps ignore
88
- * that flag); Canvas 2D draws either form identically.
89
- */
90
- const sliceHandler = {
91
- async preload(url, byteSize, entry) {
92
- const response = await fetchCompletely(url);
93
- const blob = await response.blob();
94
- verifyByteSize(url, byteSize, blob.size);
95
- const oneSlice = entry?.slicing?.mode === 'one';
96
- if (!oneSlice && typeof globalThis.createImageBitmap === 'function') {
97
- return {
98
- imageBitmap: await globalThis.createImageBitmap(blob, { premultiplyAlpha: 'premultiply' })
99
- };
100
- }
101
- if (typeof globalThis.Image === 'function') {
102
- const image = new globalThis.Image();
103
- image.src = url;
104
- await image.decode();
105
- return { image };
106
- }
107
- return {};
108
- },
109
- release(resources) {
110
- resources.imageBitmap?.close();
111
- }
112
- };
113
93
  /**
114
94
  * The retained-media strategy (video, chroma-key-video, audio): fetch the
115
95
  * full file and retain it as an object URL so later playback does not touch
@@ -140,8 +120,8 @@ const unknownKindHandler = {
140
120
  }
141
121
  };
142
122
  const runtimeHandlerCatalogue = {
143
- image: imageHandler,
144
- slice: sliceHandler,
123
+ image: decodedImageHandler,
124
+ slice: decodedImageHandler,
145
125
  video: retainedMediaHandler,
146
126
  'chroma-key-video': retainedMediaHandler,
147
127
  audio: retainedMediaHandler
@@ -16,12 +16,6 @@ export interface EntrySource {
16
16
  url: string;
17
17
  kind: string;
18
18
  byteSize: number;
19
- /**
20
- * The (possibly inlined) entry the source came from. Kind strategies read
21
- * per-entry metadata from it — the slice handler picks its retention form
22
- * by slicing mode. Orchestration itself never looks inside.
23
- */
24
- entry: Record<string, unknown>;
25
19
  }
26
20
  /** Reject an entry `byteSize` that is not a non-negative safe integer. */
27
21
  export declare function assertEntryByteSize(src: string, byteSize: unknown): asserts byteSize is number;
@@ -57,8 +51,8 @@ export declare class Manifest {
57
51
  * Drop this manifest's holds on everything it preloaded. Assets shared
58
52
  * with another holder (a manifest or a `retainEntry` handle) stay fully
59
53
  * alive for that holder; assets nobody holds anymore are disposed via their
60
- * kind's release strategy (retained media object URLs are revoked, slice
61
- * bitmaps closed, decoded image references dropped). Components fall back
54
+ * kind's release strategy (retained media object URLs are revoked,
55
+ * decoded image references dropped). Components fall back
62
56
  * to the network URL.
63
57
  *
64
58
  * Safe in every state: before or without `preload()` it is a no-op, and
@@ -34,8 +34,7 @@ export function collectEntrySources(entry) {
34
34
  id: record.id,
35
35
  url: record.src,
36
36
  kind: record.kind,
37
- byteSize: record.byteSize,
38
- entry: record
37
+ byteSize: record.byteSize
39
38
  });
40
39
  }
41
40
  for (const nested of Object.values(record))
@@ -97,8 +96,8 @@ export class Manifest {
97
96
  * Drop this manifest's holds on everything it preloaded. Assets shared
98
97
  * with another holder (a manifest or a `retainEntry` handle) stay fully
99
98
  * alive for that holder; assets nobody holds anymore are disposed via their
100
- * kind's release strategy (retained media object URLs are revoked, slice
101
- * bitmaps closed, decoded image references dropped). Components fall back
99
+ * kind's release strategy (retained media object URLs are revoked,
100
+ * decoded image references dropped). Components fall back
102
101
  * to the network URL.
103
102
  *
104
103
  * Safe in every state: before or without `preload()` it is a no-op, and
@@ -132,7 +131,7 @@ export class Manifest {
132
131
  const acquisitions = [];
133
132
  for (const [url, source] of sourceByUrl) {
134
133
  held.add(url);
135
- acquisitions.push(acquireSource(url, source.kind, source.byteSize, source.entry));
134
+ acquisitions.push(acquireSource(url, source.kind, source.byteSize));
136
135
  }
137
136
  const outcomes = await Promise.allSettled(acquisitions);
138
137
  const failure = outcomes.find((outcome) => outcome.status === 'rejected');
@@ -5,7 +5,7 @@
5
5
  * load clears the in-flight state so the URL stays retryable; the caller
6
6
  * rolls back its own hold via `releaseSource`.
7
7
  */
8
- export declare function acquireSource(url: string, kind: string, byteSize: number, sourceEntry?: Record<string, unknown>): Promise<void>;
8
+ export declare function acquireSource(url: string, kind: string, byteSize: number): Promise<void>;
9
9
  /**
10
10
  * Drop one hold on `url`. At zero holds the kind handler's release strategy
11
11
  * runs (object URL revoked) and the entry is cleared — unless the load is
@@ -20,13 +20,11 @@ export declare function releaseSource(url: string): void;
20
20
  */
21
21
  export declare function resolveRetainedSource(url: string): string;
22
22
  /**
23
- * The retained decoded source of an image or slice URL while a holder holds
24
- * it — a multi-cell slice bitmap or decoded image element — or `null` when
25
- * not (or no longer) preloaded; the consumer then
26
- * falls back to an async image load. Never returns a closed bitmap: closing
27
- * happens only when the entry is dropped from the registry.
23
+ * The retained decoded element of an image or slice URL while a holder holds
24
+ * it, or `null` when not (or no longer) preloaded; the consumer then falls
25
+ * back to an async image load.
28
26
  */
29
- export declare function resolveRetainedImageSource(url: string): ImageBitmap | HTMLImageElement | null;
27
+ export declare function resolveRetainedImageSource(url: string): HTMLImageElement | null;
30
28
  /**
31
29
  * A source's preload state, for render-time diagnostics:
32
30
  * - `loaded` — fully preloaded and held (every kind resolves non-null
@@ -26,7 +26,7 @@ const registryByUrl = new Map();
26
26
  * load clears the in-flight state so the URL stays retryable; the caller
27
27
  * rolls back its own hold via `releaseSource`.
28
28
  */
29
- export function acquireSource(url, kind, byteSize, sourceEntry) {
29
+ export function acquireSource(url, kind, byteSize) {
30
30
  let entry = registryByUrl.get(url);
31
31
  if (!entry) {
32
32
  entry = { kind, byteSize, referenceCount: 0, pending: null, resources: null };
@@ -42,7 +42,7 @@ export function acquireSource(url, kind, byteSize, sourceEntry) {
42
42
  if (!pending) {
43
43
  const acquired = entry;
44
44
  pending = runtimeHandlerFor(kind)
45
- .preload(url, byteSize, sourceEntry)
45
+ .preload(url, byteSize)
46
46
  .then((resources) => {
47
47
  acquired.pending = null;
48
48
  if (acquired.referenceCount === 0) {
@@ -92,15 +92,12 @@ export function resolveRetainedSource(url) {
92
92
  return registryByUrl.get(url)?.resources?.objectUrl ?? url;
93
93
  }
94
94
  /**
95
- * The retained decoded source of an image or slice URL while a holder holds
96
- * it — a multi-cell slice bitmap or decoded image element — or `null` when
97
- * not (or no longer) preloaded; the consumer then
98
- * falls back to an async image load. Never returns a closed bitmap: closing
99
- * happens only when the entry is dropped from the registry.
95
+ * The retained decoded element of an image or slice URL while a holder holds
96
+ * it, or `null` when not (or no longer) preloaded; the consumer then falls
97
+ * back to an async image load.
100
98
  */
101
99
  export function resolveRetainedImageSource(url) {
102
- const resources = registryByUrl.get(url)?.resources;
103
- return resources?.imageBitmap ?? resources?.image ?? null;
100
+ return registryByUrl.get(url)?.resources?.image ?? null;
104
101
  }
105
102
  export function sourceStatus(url) {
106
103
  const entry = registryByUrl.get(url);
@@ -13,7 +13,7 @@ import type { ManifestEntry } from '../core/index.js';
13
13
  */
14
14
  export type RetainedSourceOf<E extends ManifestEntry> = E extends {
15
15
  kind: 'image' | 'slice';
16
- } ? HTMLImageElement | ImageBitmap : E extends {
16
+ } ? HTMLImageElement : E extends {
17
17
  kind: 'video' | 'chroma-key-video' | 'audio';
18
18
  } ? string : never;
19
19
  /** One hold on an entry's loaded source, from `retainEntry`. */
@@ -43,7 +43,7 @@ export async function retainEntry(entry) {
43
43
  const { id, kind, src, byteSize } = entry;
44
44
  assertEntryByteSize(src, byteSize);
45
45
  try {
46
- await acquireSource(src, kind, byteSize, entry);
46
+ await acquireSource(src, kind, byteSize);
47
47
  }
48
48
  catch (cause) {
49
49
  releaseSource(src);
@@ -1,18 +1,10 @@
1
1
  import type { SliceManifestEntry } from '../core/index.js';
2
- /** What a committed slice draws from: a decoded element or a retained bitmap. */
3
- export type SliceRenderSource = HTMLImageElement | ImageBitmap;
4
2
  export interface LoadedSliceSnapshot {
5
3
  entry: SliceManifestEntry;
6
- image: SliceRenderSource;
4
+ image: HTMLImageElement;
7
5
  }
8
6
  export type SliceImageLoader = (url: string) => Promise<HTMLImageElement>;
9
- export type RetainedSliceSourceResolver = (url: string) => SliceRenderSource | null;
10
- /**
11
- * Whether a committed source can no longer be drawn: a preload-retained
12
- * bitmap is closed when its last hold is released, and drawing a closed
13
- * bitmap throws. A detached source is dropped and reloaded asynchronously.
14
- */
15
- export declare function isDetachedSliceSource(image: SliceRenderSource): boolean;
7
+ export type RetainedSliceSourceResolver = (url: string) => HTMLImageElement | null;
16
8
  export declare function loadSliceImage(url: string): Promise<HTMLImageElement>;
17
9
  /** Coordinates requested slice entries with the last image-ready snapshot. */
18
10
  export declare class SliceSourceLifecycle {
@@ -1,12 +1,4 @@
1
1
  import { resolveRetainedImageSource } from './preload-registry.js';
2
- /**
3
- * Whether a committed source can no longer be drawn: a preload-retained
4
- * bitmap is closed when its last hold is released, and drawing a closed
5
- * bitmap throws. A detached source is dropped and reloaded asynchronously.
6
- */
7
- export function isDetachedSliceSource(image) {
8
- return typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap && image.width === 0;
9
- }
10
2
  export function loadSliceImage(url) {
11
3
  const image = new Image();
12
4
  image.decoding = 'async';
@@ -32,12 +24,11 @@ export class SliceSourceLifecycle {
32
24
  return;
33
25
  this.#requestedEntry = entry;
34
26
  const displayed = this.#displayedSnapshot;
35
- if (displayed?.entry.src === entry.src && !isDetachedSliceSource(displayed.image)) {
27
+ if (displayed?.entry.src === entry.src) {
36
28
  this.#commitSnapshot({ entry, image: displayed.image });
37
29
  return;
38
30
  }
39
- // A preload-retained source (the pinned bitmap of a multi-cell slice,
40
- // the decoded element of a one-slice) commits synchronously — the slice
31
+ // A preload-retained decoded element commits synchronously — the slice
41
32
  // paints in its mount frame instead of one decode roundtrip later.
42
33
  const retained = this.#resolveRetained(entry.src);
43
34
  if (retained) {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Read-only filesystem, sharp, and ffprobe work is dominated by I/O and child
3
+ * process latency, so a handful of concurrent jobs hides most of it without
4
+ * flooding libuv's thread pool or spawning one process per asset.
5
+ */
6
+ export declare const IO_CONCURRENCY = 8;
7
+ /**
8
+ * Map items with at most `limit` callbacks pending. Results keep input order,
9
+ * so callers stay deterministic regardless of completion order. A rejection
10
+ * rejects the whole map; callers that must not fail wholesale catch per item.
11
+ */
12
+ export declare function mapConcurrent<T, R>(items: readonly T[], limit: number, map: (item: T, index: number) => Promise<R>): Promise<R[]>;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Read-only filesystem, sharp, and ffprobe work is dominated by I/O and child
3
+ * process latency, so a handful of concurrent jobs hides most of it without
4
+ * flooding libuv's thread pool or spawning one process per asset.
5
+ */
6
+ export const IO_CONCURRENCY = 8;
7
+ /**
8
+ * Map items with at most `limit` callbacks pending. Results keep input order,
9
+ * so callers stay deterministic regardless of completion order. A rejection
10
+ * rejects the whole map; callers that must not fail wholesale catch per item.
11
+ */
12
+ export async function mapConcurrent(items, limit, map) {
13
+ const results = new Array(items.length);
14
+ let next = 0;
15
+ async function worker() {
16
+ while (next < items.length) {
17
+ const index = next++;
18
+ results[index] = await map(items[index], index);
19
+ }
20
+ }
21
+ await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker));
22
+ return results;
23
+ }
@@ -8,6 +8,7 @@
8
8
  import { join } from 'node:path';
9
9
  import sharp from 'sharp';
10
10
  import { compareCodeUnits } from '../core/index.js';
11
+ import { IO_CONCURRENCY, mapConcurrent } from './concurrency.js';
11
12
  export const IMAGE_DUPLICATE_HASH_SIZE = 8;
12
13
  export const IMAGE_DUPLICATE_MAX_DISTANCE = 5;
13
14
  export const IMAGE_DUPLICATE_MAX_COLOR_DISTANCE = 32;
@@ -98,20 +99,21 @@ export class ImageDuplicateDetector {
98
99
  if (!activeHashes.has(hash))
99
100
  this.#fingerprints.delete(hash);
100
101
  }
101
- const readable = [];
102
- for (const [id, record] of images) {
102
+ const fingerprints = await mapConcurrent(images, IO_CONCURRENCY, async ([id, record]) => {
103
103
  let fingerprint = this.#fingerprints.get(record.rawHash);
104
104
  if (!fingerprint) {
105
105
  fingerprint = imageFingerprint(join(rawDir, record.raw), IMAGE_DUPLICATE_HASH_SIZE);
106
106
  this.#fingerprints.set(record.rawHash, fingerprint);
107
107
  }
108
108
  try {
109
- readable.push({ id, fingerprint: await fingerprint });
109
+ return { id, fingerprint: await fingerprint };
110
110
  }
111
111
  catch {
112
112
  this.#fingerprints.delete(record.rawHash);
113
+ return null;
113
114
  }
114
- }
115
+ });
116
+ const readable = fingerprints.filter((entry) => entry !== null);
115
117
  const duplicates = new Map();
116
118
  for (let leftIndex = 0; leftIndex < readable.length; leftIndex++) {
117
119
  for (let rightIndex = leftIndex + 1; rightIndex < readable.length; rightIndex++) {
@@ -42,6 +42,13 @@ export declare class FundusOperations {
42
42
  openUsageLocation(token: unknown): Promise<void>;
43
43
  /** Read-only state snapshot, serialized with mutations but never processing or writing. */
44
44
  state(): Promise<FundusStateDto>;
45
+ /**
46
+ * Fill the image-fingerprint and proxy-probe caches without scanning raws or
47
+ * joining the operation queue, so an early editor reconcile shares this
48
+ * in-flight work instead of waiting behind it. Both caches are keyed by
49
+ * content, so a concurrent mutation can only make an entry unused.
50
+ */
51
+ warmCaches(): Promise<void>;
45
52
  /** Reconcile only changes that are safe without a user decision: unique moves and deletions. */
46
53
  reconcileRawState(): Promise<FundusStateDto>;
47
54
  /** Adopt an already-existing raw file without copying, renaming, or deleting it. */
@@ -11,7 +11,7 @@ import { assetIdError, allDeliveredAssetIds, canonicalizeParameters, collectAllR
11
11
  import { LibraryStore } from './library-store.js';
12
12
  import { generateManifestModules, locateGeneratedAssetDeclaration, manifestModuleFileName } from './codegen.js';
13
13
  import { descriptorsByType, serverPluginForType, serverPluginsByType } from './plugins.js';
14
- import { processAssets, readCurrentProxy } from './processing/processor.js';
14
+ import { processAssets, ProxyInfoCache, readCurrentProxy } from './processing/processor.js';
15
15
  import { encodeRelativePathForUrl } from './folder-filesystem.js';
16
16
  import { classifyRawDirectory, verifyStableRawFile } from './raw-reconciliation.js';
17
17
  import { rawSafetyIssues } from './raw-safety.js';
@@ -19,6 +19,7 @@ import { serverImporters } from './importers.js';
19
19
  import { SourceImportError } from './importers/contract.js';
20
20
  import { validateFigmaSourceParameters } from './importers/figma.js';
21
21
  import { currentGitBranch, gitAssetChanges } from './git.js';
22
+ import { IO_CONCURRENCY, mapConcurrent } from './concurrency.js';
22
23
  import { FundusUsageAnalyzer, usageSnapshotRevision } from './usage.js';
23
24
  import { ImageDuplicateDetector } from './image-duplicates.js';
24
25
  /**
@@ -71,6 +72,7 @@ export class FundusOperations {
71
72
  #importers;
72
73
  #usageAnalyzer;
73
74
  #duplicateDetector = new ImageDuplicateDetector();
75
+ #proxyInfoCache = new ProxyInfoCache();
74
76
  #operationQueue = Promise.resolve();
75
77
  #pipelineQueue = Promise.resolve();
76
78
  constructor(config, registry = serverPluginsByType, importerRegistry = serverImporters) {
@@ -180,6 +182,20 @@ export class FundusOperations {
180
182
  const classification = await this.#classifyRawDirectory(library);
181
183
  return this.#snapshotState(library, classification);
182
184
  }
185
+ /**
186
+ * Fill the image-fingerprint and proxy-probe caches without scanning raws or
187
+ * joining the operation queue, so an early editor reconcile shares this
188
+ * in-flight work instead of waiting behind it. Both caches are keyed by
189
+ * content, so a concurrent mutation can only make an entry unused.
190
+ */
191
+ async warmCaches() {
192
+ const library = await this.#store.read();
193
+ const delivered = allDeliveredAssetIds(computeLibraryMembership(library, collectAllReferences(library, descriptorsByType(this.#registry))));
194
+ await Promise.all([
195
+ this.#duplicateDetector.find(library, this.#config.rawDir),
196
+ mapConcurrent([...delivered].sort(compareCodeUnits), IO_CONCURRENCY, (assetId) => this.#proxyState(assetId, library.assets[assetId], delivered))
197
+ ]);
198
+ }
183
199
  /** Reconcile only changes that are safe without a user decision: unique moves and deletions. */
184
200
  reconcileRawState() {
185
201
  return this.#enqueueOperation(async () => {
@@ -921,15 +937,42 @@ export class FundusOperations {
921
937
  state: await this.#snapshotState(latestLibrary, latestClassification, pipelineIssues)
922
938
  };
923
939
  }
940
+ async #proxyState(assetId, record, delivered) {
941
+ if (!delivered.has(assetId))
942
+ return { proxy: null };
943
+ try {
944
+ const currentProxy = await readCurrentProxy(assetId, record, this.#config, this.#registry, this.#proxyInfoCache);
945
+ if (!currentProxy) {
946
+ return {
947
+ proxy: null,
948
+ warning: 'Proxy is stale or missing — it will be (re)processed on the next save.'
949
+ };
950
+ }
951
+ return {
952
+ proxy: {
953
+ url: `/files/proxy/${encodeRelativePathForUrl(currentProxy.relativePath)}`,
954
+ upToDate: true,
955
+ ...currentProxy
956
+ }
957
+ };
958
+ }
959
+ catch (cause) {
960
+ return { proxy: null, warning: cause instanceof Error ? cause.message : String(cause) };
961
+ }
962
+ }
924
963
  async #buildState(library, issues, classification) {
925
- const [gitBranch, changedGitAssets] = await Promise.all([
926
- currentGitBranch(this.#config.projectRoot),
927
- gitAssetChanges(this.#config.projectRoot, this.#config.library, library)
928
- ]);
929
964
  const referencesByAsset = collectAllReferences(library, descriptorsByType(this.#registry));
930
- const duplicatesByAsset = await this.#duplicateDetector.find(library, this.#config.rawDir);
931
965
  const membershipByManifest = computeLibraryMembership(library, referencesByAsset);
932
966
  const delivered = allDeliveredAssetIds(membershipByManifest);
967
+ const assetIds = Object.keys(library.assets).sort(compareCodeUnits);
968
+ // Independent I/O-bound reads; run together so large libraries load in parallel.
969
+ const [gitBranch, changedGitAssets, duplicatesByAsset, proxies] = await Promise.all([
970
+ currentGitBranch(this.#config.projectRoot),
971
+ gitAssetChanges(this.#config.projectRoot, this.#config.library, library),
972
+ this.#duplicateDetector.find(library, this.#config.rawDir),
973
+ mapConcurrent(assetIds, IO_CONCURRENCY, (assetId) => this.#proxyState(assetId, library.assets[assetId], delivered))
974
+ ]);
975
+ this.#proxyInfoCache.retain(delivered);
933
976
  // Per asset: the manifests it is a passive member of, with attribution.
934
977
  const passiveManifestsByAsset = new Map();
935
978
  for (const manifestName of Object.keys(library.manifests).sort(compareCodeUnits)) {
@@ -954,7 +997,7 @@ export class FundusOperations {
954
997
  requiredByAsset.set(dependency.id, [...dependency.sources]);
955
998
  }
956
999
  const assets = [];
957
- for (const assetId of Object.keys(library.assets).sort(compareCodeUnits)) {
1000
+ for (const [index, assetId] of assetIds.entries()) {
958
1001
  const record = library.assets[assetId];
959
1002
  const warnings = [];
960
1003
  for (const issue of issues) {
@@ -971,25 +1014,9 @@ export class FundusOperations {
971
1014
  if (record.importSource && !this.#importers.has(record.importSource.importer)) {
972
1015
  warnings.push(`Importer "${record.importSource.importer}" is unavailable; the local raw file remains usable.`);
973
1016
  }
974
- let proxy = null;
975
- if (delivered.has(assetId)) {
976
- try {
977
- const currentProxy = await readCurrentProxy(assetId, record, this.#config, this.#registry);
978
- if (currentProxy) {
979
- proxy = {
980
- url: `/files/proxy/${encodeRelativePathForUrl(currentProxy.relativePath)}`,
981
- upToDate: true,
982
- ...currentProxy
983
- };
984
- }
985
- else {
986
- warnings.push('Proxy is stale or missing — it will be (re)processed on the next save.');
987
- }
988
- }
989
- catch (cause) {
990
- warnings.push(cause instanceof Error ? cause.message : String(cause));
991
- }
992
- }
1017
+ const { proxy, warning: proxyWarning } = proxies[index];
1018
+ if (proxyWarning)
1019
+ warnings.push(proxyWarning);
993
1020
  assets.push({
994
1021
  id: assetId,
995
1022
  type: record.type,
@@ -1,3 +1,19 @@
1
+ /**
2
+ * The processing pipeline: every delivered asset gets exactly one
3
+ * proxy named `<assetId>.<jobHash>.<extension>`. Delivered = explicit or
4
+ * passive member of ≥ 1 manifest (codegen inlines referenced
5
+ * entries, so a passive member's proxy must exist even when the asset is in
6
+ * no manifest itself). Because the name is computable without running the job,
7
+ * staleness is a name check: expected name exists → up to date, else →
8
+ * process. Afterwards, files in the proxy directory that no asset expects are
9
+ * pruned.
10
+ *
11
+ * Type-agnostic: every per-type decision (preset resolution, extension, the
12
+ * sharp/ffmpeg step, proxy probing) lives on the server plugins; this module
13
+ * only consults the registry — which it takes as a parameter, so tests can
14
+ * drive the pipeline with fixture asset types.
15
+ */
16
+ import { type Stats } from 'node:fs';
1
17
  import { type AssetId, type AssetRecord, type Library } from '../../core/index.js';
2
18
  import type { ResolvedFundusConfig } from '../config-loader.js';
3
19
  import type { AssetProxyInfo, ProxyOutputInfo, ServerPluginRegistry } from '../plugins/contract.js';
@@ -35,8 +51,20 @@ export declare function expectedProxyRelativePath(assetId: string, record: Asset
35
51
  * strategy: image-backed proxies via sharp, media proxies via ffprobe.
36
52
  */
37
53
  export declare function readProxyOutputInfo(record: AssetRecord, proxyPath: string, config: ResolvedFundusConfig, registry?: ServerPluginRegistry): Promise<ProxyOutputInfo>;
54
+ /**
55
+ * Probed proxy output info, reused across editor snapshots so unchanged proxies
56
+ * skip sharp and ffprobe. Proxy names are content-addressed, so an entry stays
57
+ * valid while the proxy and the raw source handed to the probe keep their stat
58
+ * identity. In-flight probes are shared, so concurrent readers never probe twice.
59
+ */
60
+ export declare class ProxyInfoCache {
61
+ #private;
62
+ read(assetId: AssetId, proxyPath: string, proxyStat: Stats, rawPath: string, probe: () => Promise<ProxyOutputInfo>): Promise<ProxyOutputInfo>;
63
+ /** Forget assets that no longer exist or are no longer delivered. */
64
+ retain(assetIds: ReadonlySet<AssetId>): void;
65
+ }
38
66
  /** Read one asset's current proxy projection, or null when its expected file is absent. */
39
- export declare function readCurrentProxy(assetId: string, record: AssetRecord, config: ResolvedFundusConfig, registry?: ServerPluginRegistry): Promise<AssetProxyInfo | null>;
67
+ export declare function readCurrentProxy(assetId: string, record: AssetRecord, config: ResolvedFundusConfig, registry?: ServerPluginRegistry, cache?: ProxyInfoCache): Promise<AssetProxyInfo | null>;
40
68
  /**
41
69
  * The delivered set: every explicit or passive member of ≥ 1 manifest —
42
70
  * derived by the shared membership computation. Assets outside
@@ -65,17 +65,54 @@ export function expectedProxyRelativePath(assetId, record, config, registry = se
65
65
  export async function readProxyOutputInfo(record, proxyPath, config, registry = serverPluginsByType) {
66
66
  return serverPluginForType(record.type, registry).readProxyInfo(proxyPath, config, join(config.rawDir, record.raw));
67
67
  }
68
+ function statIdentity(value) {
69
+ return `${value.dev}:${value.ino}:${value.size}:${value.mtimeMs}:${value.ctimeMs}`;
70
+ }
71
+ /**
72
+ * Probed proxy output info, reused across editor snapshots so unchanged proxies
73
+ * skip sharp and ffprobe. Proxy names are content-addressed, so an entry stays
74
+ * valid while the proxy and the raw source handed to the probe keep their stat
75
+ * identity. In-flight probes are shared, so concurrent readers never probe twice.
76
+ */
77
+ export class ProxyInfoCache {
78
+ #entries = new Map();
79
+ async read(assetId, proxyPath, proxyStat, rawPath, probe) {
80
+ const rawIdentity = await stat(rawPath).then(statIdentity, () => 'missing');
81
+ const identity = `${proxyPath}|${statIdentity(proxyStat)}|${rawIdentity}`;
82
+ const cached = this.#entries.get(assetId);
83
+ if (cached?.identity === identity)
84
+ return cached.info;
85
+ const entry = { identity, info: probe() };
86
+ this.#entries.set(assetId, entry);
87
+ try {
88
+ return await entry.info;
89
+ }
90
+ catch (cause) {
91
+ if (this.#entries.get(assetId) === entry)
92
+ this.#entries.delete(assetId);
93
+ throw cause;
94
+ }
95
+ }
96
+ /** Forget assets that no longer exist or are no longer delivered. */
97
+ retain(assetIds) {
98
+ for (const assetId of this.#entries.keys()) {
99
+ if (!assetIds.has(assetId))
100
+ this.#entries.delete(assetId);
101
+ }
102
+ }
103
+ }
68
104
  /** Read one asset's current proxy projection, or null when its expected file is absent. */
69
- export async function readCurrentProxy(assetId, record, config, registry = serverPluginsByType) {
105
+ export async function readCurrentProxy(assetId, record, config, registry = serverPluginsByType, cache) {
70
106
  const fileName = expectedProxyFileName(assetId, record, config, registry);
71
107
  const relativePath = expectedProxyRelativePath(assetId, record, config, registry);
72
108
  const proxyPath = join(config.proxyDir, relativePath);
73
109
  if (!existsSync(proxyPath))
74
110
  return null;
75
- const [outputInfo, proxyStat] = await Promise.all([
76
- readProxyOutputInfo(record, proxyPath, config, registry),
77
- stat(proxyPath)
78
- ]);
111
+ const proxyStat = await stat(proxyPath);
112
+ const probe = () => readProxyOutputInfo(record, proxyPath, config, registry);
113
+ const outputInfo = cache
114
+ ? await cache.read(assetId, proxyPath, proxyStat, join(config.rawDir, record.raw), probe)
115
+ : await probe();
79
116
  return { assetId, fileName, relativePath, ...outputInfo, fileSize: proxyStat.size };
80
117
  }
81
118
  /**