cursedbelt-core 2.0.0 → 2.1.1

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 (155) hide show
  1. package/dist/core/activity/index.d.ts +2 -2
  2. package/dist/core/activity/index.js +1 -1
  3. package/dist/core/activity/model.d.ts +2 -2
  4. package/dist/core/analytics/index.d.ts +2 -2
  5. package/dist/core/analytics/index.js +2 -2
  6. package/dist/core/category/index.d.ts +3 -3
  7. package/dist/core/category/index.js +2 -2
  8. package/dist/core/category/memoryStore.d.ts +1 -1
  9. package/dist/core/category/memoryStore.js +1 -1
  10. package/dist/core/category/tree.d.ts +1 -1
  11. package/dist/core/colorScheme/index.d.ts +1 -1
  12. package/dist/core/colorScheme/index.js +1 -1
  13. package/dist/core/duplicates/index.d.ts +115 -0
  14. package/dist/core/duplicates/index.js +177 -0
  15. package/dist/core/folder-tree/actions.d.ts +1 -1
  16. package/dist/core/folder-tree/actions.js +1 -1
  17. package/dist/core/folder-tree/index.d.ts +3 -3
  18. package/dist/core/folder-tree/index.js +3 -3
  19. package/dist/core/folder-tree/tree.js +1 -1
  20. package/dist/core/master-lock/index.d.ts +4 -4
  21. package/dist/core/master-lock/index.js +4 -4
  22. package/dist/core/master-lock/wire.d.ts +1 -1
  23. package/dist/core/media/ffmpeg.d.ts +1 -1
  24. package/dist/core/media/format.d.ts +1 -1
  25. package/dist/core/media/index.d.ts +12 -12
  26. package/dist/core/media/index.js +11 -11
  27. package/dist/core/media/mediaItem.d.ts +1 -1
  28. package/dist/core/media/renderPlan.d.ts +1 -1
  29. package/dist/core/media/renderPlan.js +1 -1
  30. package/dist/core/media/validate.d.ts +1 -1
  31. package/dist/core/media/validate.js +1 -1
  32. package/dist/core/navigation/coverageTest.d.ts +1 -1
  33. package/dist/core/navigation/coverageTest.js +1 -1
  34. package/dist/core/navigation/index.d.ts +6 -6
  35. package/dist/core/navigation/index.js +6 -6
  36. package/dist/core/navigation/migrateNavPreferences.d.ts +1 -1
  37. package/dist/core/navigation/navigationSelectors.d.ts +1 -1
  38. package/dist/core/navigation/navigationSelectors.js +1 -1
  39. package/dist/core/navigation/searchCatalogue.d.ts +1 -1
  40. package/dist/core/palette/fuzzyRank.d.ts +1 -1
  41. package/dist/core/palette/index.d.ts +3 -3
  42. package/dist/core/palette/index.js +3 -3
  43. package/dist/core/sharing/index.d.ts +2 -2
  44. package/dist/core/sharing/index.js +1 -1
  45. package/dist/core/tokens/registry.d.ts +2 -2
  46. package/dist/core/tokens/registry.js +1 -1
  47. package/dist/core/uiScale/index.d.ts +2 -2
  48. package/dist/core/uiScale/index.js +2 -2
  49. package/dist/core/uiScale/uiScaleStore.js +1 -1
  50. package/dist/core/wire/__fixtures__/rowsMarshalWorker.js +1 -1
  51. package/dist/core/wire/index.d.ts +4 -4
  52. package/dist/core/wire/index.js +4 -4
  53. package/dist/core/wire/ingress.d.ts +1 -1
  54. package/dist/core/wire/ingress.js +3 -3
  55. package/dist/core/wire/marshalWorker.d.ts +1 -1
  56. package/dist/core/wire/marshalWorker.js +2 -2
  57. package/dist/core/wire/marshalling.js +1 -1
  58. package/dist/ctgr/chunkAccumulator.d.ts +1 -1
  59. package/dist/ctgr/chunkAccumulator.js +2 -2
  60. package/dist/ctgr/chunkStore.d.ts +1 -1
  61. package/dist/ctgr/chunkStore.js +2 -2
  62. package/dist/ctgr/codec.d.ts +1 -1
  63. package/dist/ctgr/codec.js +5 -5
  64. package/dist/ctgr/index.d.ts +9 -9
  65. package/dist/ctgr/index.js +9 -9
  66. package/dist/ctgr/v0compat.d.ts +1 -1
  67. package/dist/ctgr/v0compat.js +2 -2
  68. package/dist/index.d.ts +2 -2
  69. package/dist/index.js +2 -2
  70. package/dist/testing/index.d.ts +3 -3
  71. package/dist/testing/index.js +3 -3
  72. package/package.json +10 -4
  73. package/src/config/appConfig.spec.ts +1 -1
  74. package/src/core/activity/index.ts +2 -2
  75. package/src/core/activity/model.ts +2 -2
  76. package/src/core/analytics/index.ts +2 -2
  77. package/src/core/analytics/range.spec.ts +1 -1
  78. package/src/core/analytics/schedule.spec.ts +1 -1
  79. package/src/core/category/category.spec.ts +1 -1
  80. package/src/core/category/index.ts +3 -3
  81. package/src/core/category/memoryStore.ts +2 -2
  82. package/src/core/category/tree.ts +1 -1
  83. package/src/core/color.spec.ts +1 -1
  84. package/src/core/colorScheme/colorScheme.spec.ts +1 -1
  85. package/src/core/colorScheme/index.ts +1 -1
  86. package/src/core/download.spec.ts +1 -1
  87. package/src/core/duplicates/index.spec.ts +116 -0
  88. package/src/core/duplicates/index.ts +203 -0
  89. package/src/core/events/ccEventBus.spec.ts +1 -1
  90. package/src/core/file-tree/fileTreeModel.spec.ts +1 -1
  91. package/src/core/folder-tree/actions.spec.ts +2 -2
  92. package/src/core/folder-tree/actions.ts +2 -2
  93. package/src/core/folder-tree/index.ts +3 -3
  94. package/src/core/folder-tree/path.spec.ts +1 -1
  95. package/src/core/folder-tree/tree.spec.ts +1 -1
  96. package/src/core/folder-tree/tree.ts +1 -1
  97. package/src/core/master-lock/index.ts +4 -4
  98. package/src/core/master-lock/kdf.spec.ts +2 -2
  99. package/src/core/master-lock/presence.spec.ts +1 -1
  100. package/src/core/master-lock/wire.spec.ts +1 -1
  101. package/src/core/master-lock/wire.ts +1 -1
  102. package/src/core/media/bgRemoval.spec.ts +1 -1
  103. package/src/core/media/ffmpeg.spec.ts +2 -2
  104. package/src/core/media/ffmpeg.ts +1 -1
  105. package/src/core/media/format.spec.ts +1 -1
  106. package/src/core/media/format.ts +1 -1
  107. package/src/core/media/index.ts +12 -12
  108. package/src/core/media/mediaItem.spec.ts +2 -2
  109. package/src/core/media/mediaItem.ts +1 -1
  110. package/src/core/media/renderPlan.spec.ts +1 -1
  111. package/src/core/media/renderPlan.ts +2 -2
  112. package/src/core/media/segment.spec.ts +1 -1
  113. package/src/core/media/size.spec.ts +1 -1
  114. package/src/core/media/trim.spec.ts +1 -1
  115. package/src/core/media/validate.spec.ts +2 -2
  116. package/src/core/media/validate.ts +2 -2
  117. package/src/core/media/videoCuts.spec.ts +1 -1
  118. package/src/core/navigation/coverageTest.ts +2 -2
  119. package/src/core/navigation/index.ts +6 -6
  120. package/src/core/navigation/migrateNavPreferences.ts +1 -1
  121. package/src/core/navigation/navigation.spec.ts +1 -1
  122. package/src/core/navigation/navigationSelectors.ts +1 -1
  123. package/src/core/navigation/searchCatalogue.ts +1 -1
  124. package/src/core/net/reconnectSchedule.spec.ts +1 -1
  125. package/src/core/palette/fuzzyRank.ts +1 -1
  126. package/src/core/palette/index.ts +3 -3
  127. package/src/core/palette/palette.spec.ts +4 -4
  128. package/src/core/schema/projection.spec.ts +1 -1
  129. package/src/core/sharing/index.ts +2 -2
  130. package/src/core/sharing/model.spec.ts +1 -1
  131. package/src/core/tokens/registry.spec.ts +1 -1
  132. package/src/core/tokens/registry.ts +2 -2
  133. package/src/core/uiScale/index.ts +2 -2
  134. package/src/core/uiScale/uiScale.spec.ts +1 -1
  135. package/src/core/uiScale/uiScaleStore.ts +1 -1
  136. package/src/core/wire/__fixtures__/rowsMarshalWorker.ts +1 -1
  137. package/src/core/wire/index.ts +4 -4
  138. package/src/core/wire/ingress.spec.ts +2 -2
  139. package/src/core/wire/ingress.ts +3 -3
  140. package/src/core/wire/marshalWorker.spec.ts +2 -2
  141. package/src/core/wire/marshalWorker.ts +2 -2
  142. package/src/core/wire/marshalling.spec.ts +1 -1
  143. package/src/core/wire/marshalling.ts +1 -1
  144. package/src/core/wire/wireHub.spec.ts +1 -1
  145. package/src/ctgr/chunkAccumulator.ts +3 -3
  146. package/src/ctgr/chunkStore.spec.ts +2 -2
  147. package/src/ctgr/chunkStore.ts +3 -3
  148. package/src/ctgr/codec.spec.ts +4 -4
  149. package/src/ctgr/codec.ts +5 -5
  150. package/src/ctgr/index.ts +9 -9
  151. package/src/ctgr/v0compat.spec.ts +2 -2
  152. package/src/ctgr/v0compat.ts +3 -3
  153. package/src/index.ts +2 -2
  154. package/src/testTmpRoot.spec.ts +1 -1
  155. package/src/testing/index.ts +3 -3
@@ -4,5 +4,5 @@
4
4
  * The storage + query half is `cursedbelt-server/activity`; this entry is what a
5
5
  * browser, a spec or a wire type can import without pulling `bun:sqlite` in.
6
6
  */
7
- export { ACTIVITY_OUTCOMES, activityFromShareEvent, activitySubject, describeActivityEvent, describeActivityResource, isActivityAction, isActivityOutcome, } from "./model";
8
- export type { ActivityEvent, ActivityEventInput, ActivityOutcome, ActivityResource, } from "./model";
7
+ export { ACTIVITY_OUTCOMES, activityFromShareEvent, activitySubject, describeActivityEvent, describeActivityResource, isActivityAction, isActivityOutcome, } from "./model.js";
8
+ export type { ActivityEvent, ActivityEventInput, ActivityOutcome, ActivityResource, } from "./model.js";
@@ -4,4 +4,4 @@
4
4
  * The storage + query half is `cursedbelt-server/activity`; this entry is what a
5
5
  * browser, a spec or a wire type can import without pulling `bun:sqlite` in.
6
6
  */
7
- export { ACTIVITY_OUTCOMES, activityFromShareEvent, activitySubject, describeActivityEvent, describeActivityResource, isActivityAction, isActivityOutcome, } from "./model";
7
+ export { ACTIVITY_OUTCOMES, activityFromShareEvent, activitySubject, describeActivityEvent, describeActivityResource, isActivityAction, isActivityOutcome, } from "./model.js";
@@ -30,7 +30,7 @@
30
30
  * - **`id` is source-qualified** (`log:41`, `share:12`), because the stream is a
31
31
  * UNION over more than one table and two tables' autoincrements collide at 1.
32
32
  */
33
- import type { ShareEvent } from "../sharing/model";
33
+ import type { ShareEvent } from "../sharing/model.js";
34
34
  /** How it went. `denied` is its own outcome rather than an `error`: a refused
35
35
  * action is the single most interesting row in an audit trail, and one that
36
36
  * reads as a crash gets triaged as one. */
@@ -39,7 +39,7 @@ export declare const ACTIVITY_OUTCOMES: readonly ActivityOutcome[];
39
39
  export declare const isActivityOutcome: (value: unknown) => value is ActivityOutcome;
40
40
  /**
41
41
  * What was acted on: `(app, kind, id)` — deliberately identical to
42
- * {@link import("../sharing/model").ShareResource}, so a resource can be handed
42
+ * {@link import("../sharing/model.js").ShareResource}, so a resource can be handed
43
43
  * from one primitive to the other without a translation layer.
44
44
  */
45
45
  export interface ActivityResource {
@@ -14,5 +14,5 @@
14
14
  *
15
15
  * The React furniture that renders it lives in `cursedbelt/react/analytics`.
16
16
  */
17
- export * from './range';
18
- export * from './schedule';
17
+ export * from './range.js';
18
+ export * from './schedule.js';
@@ -14,5 +14,5 @@
14
14
  *
15
15
  * The React furniture that renders it lives in `cursedbelt/react/analytics`.
16
16
  */
17
- export * from './range';
18
- export * from './schedule';
17
+ export * from './range.js';
18
+ export * from './schedule.js';
@@ -1,3 +1,3 @@
1
- export type { Category, CategoryCreateInput, CategoryNode, CategoryStore, } from './types';
2
- export { buildCategoryForest, categoryPath, descendantCategoryIds, flattenCategoryForest, wouldCreateCycle, } from './tree';
3
- export { createMemoryCategoryStore } from './memoryStore';
1
+ export type { Category, CategoryCreateInput, CategoryNode, CategoryStore, } from './types.js';
2
+ export { buildCategoryForest, categoryPath, descendantCategoryIds, flattenCategoryForest, wouldCreateCycle, } from './tree.js';
3
+ export { createMemoryCategoryStore } from './memoryStore.js';
@@ -2,5 +2,5 @@
2
2
  // through `cursedbelt/react`'s categoryTree. Pure and
3
3
  // React-free (the `TreeView` adapter lives in `cursedbelt/react`), so servers
4
4
  // can import the contract + store without pulling any UI runtime.
5
- export { buildCategoryForest, categoryPath, descendantCategoryIds, flattenCategoryForest, wouldCreateCycle, } from './tree';
6
- export { createMemoryCategoryStore } from './memoryStore';
5
+ export { buildCategoryForest, categoryPath, descendantCategoryIds, flattenCategoryForest, wouldCreateCycle, } from './tree.js';
6
+ export { createMemoryCategoryStore } from './memoryStore.js';
@@ -1,4 +1,4 @@
1
- import type { Category, CategoryCreateInput } from './types';
1
+ import type { Category, CategoryCreateInput } from './types.js';
2
2
  /**
3
3
  * Zero-config in-memory {@link CategoryStore}: a per-boot `Map` per tenant, so a
4
4
  * host that wires nothing up still gets a working (non-persistent) category
@@ -1,4 +1,4 @@
1
- import { wouldCreateCycle } from './tree';
1
+ import { wouldCreateCycle } from './tree.js';
2
2
  /**
3
3
  * Zero-config in-memory {@link CategoryStore}: a per-boot `Map` per tenant, so a
4
4
  * host that wires nothing up still gets a working (non-persistent) category
@@ -1,4 +1,4 @@
1
- import type { Category, CategoryNode } from './types';
1
+ import type { Category, CategoryNode } from './types.js';
2
2
  /**
3
3
  * Would setting `childId`'s parent to `candidateParentId` create a cycle?
4
4
  * True when the candidate parent is the child itself or one of its
@@ -4,4 +4,4 @@
4
4
  * on app boot (before React mounts → no flash); the React control + hook live in
5
5
  * `cursedbelt/react` (`ThemeToggle`, `useColorScheme`).
6
6
  */
7
- export { applyColorSchemeToDocument, COLOR_SCHEME_STORAGE_KEY, type ColorSchemeMode, type ColorSchemeStorage, type ColorSchemeStore, type ColorSchemeStoreOptions, colorSchemeStore, createColorSchemeStore, getColorSchemeMode, getResolvedColorScheme, initColorScheme, type ResolvedColorScheme, setColorSchemeMode, subscribeColorScheme, toggleColorScheme, } from './colorSchemeStore';
7
+ export { applyColorSchemeToDocument, COLOR_SCHEME_STORAGE_KEY, type ColorSchemeMode, type ColorSchemeStorage, type ColorSchemeStore, type ColorSchemeStoreOptions, colorSchemeStore, createColorSchemeStore, getColorSchemeMode, getResolvedColorScheme, initColorScheme, type ResolvedColorScheme, setColorSchemeMode, subscribeColorScheme, toggleColorScheme, } from './colorSchemeStore.js';
@@ -4,4 +4,4 @@
4
4
  * on app boot (before React mounts → no flash); the React control + hook live in
5
5
  * `cursedbelt/react` (`ThemeToggle`, `useColorScheme`).
6
6
  */
7
- export { applyColorSchemeToDocument, COLOR_SCHEME_STORAGE_KEY, colorSchemeStore, createColorSchemeStore, getColorSchemeMode, getResolvedColorScheme, initColorScheme, setColorSchemeMode, subscribeColorScheme, toggleColorScheme, } from './colorSchemeStore';
7
+ export { applyColorSchemeToDocument, COLOR_SCHEME_STORAGE_KEY, colorSchemeStore, createColorSchemeStore, getColorSchemeMode, getResolvedColorScheme, initColorScheme, setColorSchemeMode, subscribeColorScheme, toggleColorScheme, } from './colorSchemeStore.js';
@@ -0,0 +1,115 @@
1
+ /**
2
+ * "Are these two files the same thing?" — the fleet's ONE answer, with no I/O in it.
3
+ *
4
+ * ── Why this is here and not in an app ──────────────────────────────────────────────────
5
+ * `binary-server` wrote these rules first (2026-08-13, the owner: *"make strategy to always
6
+ * check for duplicate binaries in same app for same user … mark likely the duplicates for
7
+ * users to confirm"*), and they were measured against a real store: the copy-counter cap, the
8
+ * camera-generic stem list and the 5% size window each exist because a wider rule proposed
9
+ * thousands of pairs nobody could ever review. `music` then needed the same question asked of
10
+ * a 5,400-track library — *"I want to see a duplicate next to the file that it is a possible
11
+ * duplicate of … The 'binary-server' app (bs) already has this feature for checking duplicate
12
+ * files. We can reuse it and enhance the customizability if needed so the main strategy is
13
+ * shared."*
14
+ *
15
+ * So the strategy lives once, here, and both consume it. What each app keeps for itself is the
16
+ * evidence only it has: bs's perceptual frame hashes, music's artist/title key.
17
+ *
18
+ * ── What is NOT here, deliberately ──────────────────────────────────────────────────────
19
+ * Anything that needs a database, a filesystem, a fetch or a picture decoder. This module is
20
+ * pure so both consumers can prove their behaviour with `bun test` instead of against a live
21
+ * store, and so a browser bundle may import it (music renders the pair side by side).
22
+ */
23
+ /**
24
+ * How far two sizes may differ and still be called the same thing.
25
+ *
26
+ * A re-encode at a different quality is the case this exists for, and those land within a few
27
+ * percent. Zero would make a name match a strict subset of "same name AND same size", which
28
+ * misses precisely the files worth asking about; much larger starts pairing a clip with its
29
+ * own trailer.
30
+ */
31
+ export declare const SIZE_TOLERANCE = 0.05;
32
+ /**
33
+ * How far two playing times may differ and still be the same recording.
34
+ *
35
+ * Wide enough for a re-encode (a trim of a trailing black frame, a container that rounds), far
36
+ * too narrow for the owner's 2026-08-14 report of a 3:56 video paired with a 1:52 one.
37
+ */
38
+ export declare const DURATION_TOLERANCE = 0.05;
39
+ /**
40
+ * The name to compare, reduced to what a human means by "the same file".
41
+ *
42
+ * Strips the directory, the extension, and the marks a copy picks up along the way —
43
+ * `Finder`'s ` copy`/` copy 2`, a browser's ` (1)`, a script's `_1`/`-1` — then collapses
44
+ * everything that is not a letter or a digit. `My Video (1).mp4`, `my_video.MP4` and
45
+ * `My-Video copy 2.mov` all reduce to `myvideo`.
46
+ *
47
+ * 🔴 The extension is stripped ON PURPOSE, so a re-encode (`.mov` → `.mp4`, `.wma` → `.mp3`)
48
+ * still pairs with its source. That is the single most common likely-duplicate in this fleet's
49
+ * stores and a rule that kept the extension would never see one.
50
+ *
51
+ * 🔴 **The copy-counter strip is capped at two digits**, and that cap is a measured bug fix
52
+ * (bs, 2026-08-14). It used to be `[-_]\d+$`, which eats a CAMERA SEQUENCE as readily as a copy
53
+ * marker: `IMG_4595` and `IMG_4646` both reduced to `img`, so every iPhone capture in a tenant
54
+ * shared one bucket and paired with every other one whose size was within 5%. `_1`…`_99` is
55
+ * what a copy marker actually looks like; four digits is a photo's identity, and it must
56
+ * survive.
57
+ */
58
+ export declare function normalizeName(name: string | null | undefined): string;
59
+ /**
60
+ * Stems that name a CAMERA, not a file.
61
+ *
62
+ * `IMG`, `DSC`, `PXL`, `VID` are what a device calls everything it produces; two files whose
63
+ * names reduce to one of these have told us nothing except that they came from a phone. Even
64
+ * with the counter cap above, `IMG_1.MOV` still reduces to `img`, and a bucket called "img" is
65
+ * every photo the owner has ever taken.
66
+ *
67
+ * 🔴 This rejects a MATCH, never a file. Both copies stay wherever they are, and an
68
+ * identical-bytes pair is unaffected — a checksum match never consults the name.
69
+ */
70
+ export declare const GENERIC_STEMS: ReadonlySet<string>;
71
+ /**
72
+ * A name that could belong to any file from that device tells us nothing about identity.
73
+ *
74
+ * 🔴 The trailing digit run is stripped BEFORE the lookup, and only here — never in
75
+ * {@link normalizeName}, where removing it is what made every camera capture one bucket. The
76
+ * two are asked at different moments: `normalizeName` decides whether two names are the SAME
77
+ * (so `img4595` and `img4646` must stay apart), and this decides whether a name that already
78
+ * matched is worth anything (so two files both called `Track01` have told us nothing, and a
79
+ * ripper names half a library that).
80
+ */
81
+ export declare const isGenericName: (normalized: string) => boolean;
82
+ /** Within {@link SIZE_TOLERANCE} of each other. Two zero-byte files are not "close" — they
83
+ * are two files nothing is known about. */
84
+ export declare function sizesClose(a: number, b: number, tolerance?: number): boolean;
85
+ /**
86
+ * Within {@link DURATION_TOLERANCE} of each other.
87
+ *
88
+ * 🔴 A missing or zero duration answers `null`, never `false`: "nobody has measured this yet"
89
+ * and "these are different lengths" are opposite answers, and collapsing them makes an
90
+ * unprobed library look like a library with no duplicates in it.
91
+ */
92
+ export declare function durationsClose(a: number | null | undefined, b: number | null | undefined, tolerance?: number): boolean | null;
93
+ /** One file, reduced to what this module can reason about. Units are the caller's — pass
94
+ * both durations in the same unit or not at all. */
95
+ export interface NamedFileFacts {
96
+ /** The name a person would recognise: a display title, else the last path segment. */
97
+ name: string | null | undefined;
98
+ sizeBytes: number;
99
+ /** Playing time, in whatever unit the caller uses consistently. Null = never measured. */
100
+ duration?: number | null;
101
+ }
102
+ export interface NameMatchVerdict {
103
+ match: boolean;
104
+ /** Written for the owner reading a review queue, never for a log. Empty when `match` is
105
+ * false — a non-match has nothing to explain. */
106
+ reason: string;
107
+ }
108
+ /**
109
+ * The shared "these look like the same thing" test: the NAME agrees after normalization, the
110
+ * sizes are close, and nothing about the length says otherwise.
111
+ *
112
+ * It is deliberately a QUESTION and never an action. Both consumers act only on a checksum,
113
+ * which is proof; this decides what is worth showing a person side by side.
114
+ */
115
+ export declare function likelyTheSameFile(a: NamedFileFacts, b: NamedFileFacts): NameMatchVerdict;
@@ -0,0 +1,177 @@
1
+ /**
2
+ * "Are these two files the same thing?" — the fleet's ONE answer, with no I/O in it.
3
+ *
4
+ * ── Why this is here and not in an app ──────────────────────────────────────────────────
5
+ * `binary-server` wrote these rules first (2026-08-13, the owner: *"make strategy to always
6
+ * check for duplicate binaries in same app for same user … mark likely the duplicates for
7
+ * users to confirm"*), and they were measured against a real store: the copy-counter cap, the
8
+ * camera-generic stem list and the 5% size window each exist because a wider rule proposed
9
+ * thousands of pairs nobody could ever review. `music` then needed the same question asked of
10
+ * a 5,400-track library — *"I want to see a duplicate next to the file that it is a possible
11
+ * duplicate of … The 'binary-server' app (bs) already has this feature for checking duplicate
12
+ * files. We can reuse it and enhance the customizability if needed so the main strategy is
13
+ * shared."*
14
+ *
15
+ * So the strategy lives once, here, and both consume it. What each app keeps for itself is the
16
+ * evidence only it has: bs's perceptual frame hashes, music's artist/title key.
17
+ *
18
+ * ── What is NOT here, deliberately ──────────────────────────────────────────────────────
19
+ * Anything that needs a database, a filesystem, a fetch or a picture decoder. This module is
20
+ * pure so both consumers can prove their behaviour with `bun test` instead of against a live
21
+ * store, and so a browser bundle may import it (music renders the pair side by side).
22
+ */
23
+ /**
24
+ * How far two sizes may differ and still be called the same thing.
25
+ *
26
+ * A re-encode at a different quality is the case this exists for, and those land within a few
27
+ * percent. Zero would make a name match a strict subset of "same name AND same size", which
28
+ * misses precisely the files worth asking about; much larger starts pairing a clip with its
29
+ * own trailer.
30
+ */
31
+ export const SIZE_TOLERANCE = 0.05;
32
+ /**
33
+ * How far two playing times may differ and still be the same recording.
34
+ *
35
+ * Wide enough for a re-encode (a trim of a trailing black frame, a container that rounds), far
36
+ * too narrow for the owner's 2026-08-14 report of a 3:56 video paired with a 1:52 one.
37
+ */
38
+ export const DURATION_TOLERANCE = 0.05;
39
+ /**
40
+ * The name to compare, reduced to what a human means by "the same file".
41
+ *
42
+ * Strips the directory, the extension, and the marks a copy picks up along the way —
43
+ * `Finder`'s ` copy`/` copy 2`, a browser's ` (1)`, a script's `_1`/`-1` — then collapses
44
+ * everything that is not a letter or a digit. `My Video (1).mp4`, `my_video.MP4` and
45
+ * `My-Video copy 2.mov` all reduce to `myvideo`.
46
+ *
47
+ * 🔴 The extension is stripped ON PURPOSE, so a re-encode (`.mov` → `.mp4`, `.wma` → `.mp3`)
48
+ * still pairs with its source. That is the single most common likely-duplicate in this fleet's
49
+ * stores and a rule that kept the extension would never see one.
50
+ *
51
+ * 🔴 **The copy-counter strip is capped at two digits**, and that cap is a measured bug fix
52
+ * (bs, 2026-08-14). It used to be `[-_]\d+$`, which eats a CAMERA SEQUENCE as readily as a copy
53
+ * marker: `IMG_4595` and `IMG_4646` both reduced to `img`, so every iPhone capture in a tenant
54
+ * shared one bucket and paired with every other one whose size was within 5%. `_1`…`_99` is
55
+ * what a copy marker actually looks like; four digits is a photo's identity, and it must
56
+ * survive.
57
+ */
58
+ export function normalizeName(name) {
59
+ if (!name)
60
+ return "";
61
+ const leaf = name.split(/[\\/]/).pop() ?? "";
62
+ const stem = leaf.replace(/\.[a-z0-9]{1,5}$/i, "");
63
+ return stem
64
+ .toLowerCase()
65
+ .replace(/\s*\(\d+\)\s*$/, "")
66
+ .replace(/\s+copy(\s+\d+)?\s*$/, "")
67
+ .replace(/[-_]\d{1,2}\s*$/, "")
68
+ .replace(/[^a-z0-9]+/g, "");
69
+ }
70
+ /**
71
+ * Stems that name a CAMERA, not a file.
72
+ *
73
+ * `IMG`, `DSC`, `PXL`, `VID` are what a device calls everything it produces; two files whose
74
+ * names reduce to one of these have told us nothing except that they came from a phone. Even
75
+ * with the counter cap above, `IMG_1.MOV` still reduces to `img`, and a bucket called "img" is
76
+ * every photo the owner has ever taken.
77
+ *
78
+ * 🔴 This rejects a MATCH, never a file. Both copies stay wherever they are, and an
79
+ * identical-bytes pair is unaffected — a checksum match never consults the name.
80
+ */
81
+ export const GENERIC_STEMS = new Set([
82
+ "img",
83
+ "image",
84
+ "images",
85
+ "dsc",
86
+ "dscf",
87
+ "dscn",
88
+ "pxl",
89
+ "vid",
90
+ "video",
91
+ "movie",
92
+ "mov",
93
+ "photo",
94
+ "pic",
95
+ "picture",
96
+ "screenshot",
97
+ "screenrecording",
98
+ "untitled",
99
+ "unknown",
100
+ "file",
101
+ "download",
102
+ "scan",
103
+ "capture",
104
+ "copy",
105
+ "new",
106
+ "temp",
107
+ "tmp",
108
+ // Music's own version of the same problem: an untagged rip is called this by every ripper
109
+ // ever written, and `track01` from two albums is two different songs.
110
+ "track",
111
+ "audiotrack",
112
+ "unknownartist",
113
+ ]);
114
+ /**
115
+ * A name that could belong to any file from that device tells us nothing about identity.
116
+ *
117
+ * 🔴 The trailing digit run is stripped BEFORE the lookup, and only here — never in
118
+ * {@link normalizeName}, where removing it is what made every camera capture one bucket. The
119
+ * two are asked at different moments: `normalizeName` decides whether two names are the SAME
120
+ * (so `img4595` and `img4646` must stay apart), and this decides whether a name that already
121
+ * matched is worth anything (so two files both called `Track01` have told us nothing, and a
122
+ * ripper names half a library that).
123
+ */
124
+ export const isGenericName = (normalized) => normalized.length < 3 ||
125
+ GENERIC_STEMS.has(normalized) ||
126
+ GENERIC_STEMS.has(normalized.replace(/\d+$/, ""));
127
+ /** Within {@link SIZE_TOLERANCE} of each other. Two zero-byte files are not "close" — they
128
+ * are two files nothing is known about. */
129
+ export function sizesClose(a, b, tolerance = SIZE_TOLERANCE) {
130
+ if (a === b && a > 0)
131
+ return true;
132
+ const larger = Math.max(a, b);
133
+ if (larger <= 0)
134
+ return false;
135
+ return Math.abs(a - b) / larger <= tolerance;
136
+ }
137
+ /**
138
+ * Within {@link DURATION_TOLERANCE} of each other.
139
+ *
140
+ * 🔴 A missing or zero duration answers `null`, never `false`: "nobody has measured this yet"
141
+ * and "these are different lengths" are opposite answers, and collapsing them makes an
142
+ * unprobed library look like a library with no duplicates in it.
143
+ */
144
+ export function durationsClose(a, b, tolerance = DURATION_TOLERANCE) {
145
+ if (!a || !b || a <= 0 || b <= 0)
146
+ return null;
147
+ return Math.abs(a - b) / Math.max(a, b) <= tolerance;
148
+ }
149
+ /**
150
+ * The shared "these look like the same thing" test: the NAME agrees after normalization, the
151
+ * sizes are close, and nothing about the length says otherwise.
152
+ *
153
+ * It is deliberately a QUESTION and never an action. Both consumers act only on a checksum,
154
+ * which is proof; this decides what is worth showing a person side by side.
155
+ */
156
+ export function likelyTheSameFile(a, b) {
157
+ const na = normalizeName(a.name);
158
+ const nb = normalizeName(b.name);
159
+ // Two empty names are not a match with each other — it means neither file has a name worth
160
+ // comparing (a bare digest key, an untitled upload), and pairing those proposes every
161
+ // unnamed object as a duplicate of every other.
162
+ if (!na || na !== nb)
163
+ return { match: false, reason: "" };
164
+ if (isGenericName(na))
165
+ return { match: false, reason: "" };
166
+ if (!sizesClose(a.sizeBytes, b.sizeBytes))
167
+ return { match: false, reason: "" };
168
+ if (durationsClose(a.duration, b.duration) === false)
169
+ return { match: false, reason: "" };
170
+ const pct = Math.round((Math.abs(a.sizeBytes - b.sizeBytes) / Math.max(a.sizeBytes, b.sizeBytes, 1)) * 100);
171
+ return {
172
+ match: true,
173
+ reason: a.sizeBytes === b.sizeBytes
174
+ ? "same name, same size, different bytes"
175
+ : `same name, size within ${pct}%, different bytes`,
176
+ };
177
+ }
@@ -28,7 +28,7 @@
28
28
  * place that can count what is about to be lost or refuse the operation. Modelling it here
29
29
  * would mean inventing an item, and an item this model cannot see is one it cannot protect.
30
30
  */
31
- import type { FolderNode, FolderRemap } from "./tree";
31
+ import type { FolderNode, FolderRemap } from "./tree.js";
32
32
  /**
33
33
  * Move everything at or under `source` INTO `destination`, and stop `source` existing.
34
34
  *
@@ -28,7 +28,7 @@
28
28
  * place that can count what is about to be lost or refuse the operation. Modelling it here
29
29
  * would mean inventing an item, and an item this model cannot see is one it cannot protect.
30
30
  */
31
- import { folderPathKey, isFolderPathUnder, parseFolderPath, serializeFolderPath, } from "./path";
31
+ import { folderPathKey, isFolderPathUnder, parseFolderPath, serializeFolderPath, } from "./path.js";
32
32
  /**
33
33
  * Move everything at or under `source` INTO `destination`, and stop `source` existing.
34
34
  *
@@ -1,3 +1,3 @@
1
- export { FOLDER_PATH_DELIMITER, FOLDER_PATH_ESCAPE, folderDescendantPrefix, folderPathKey, formatFolderPath, isFolderPathUnder, isValidFolderName, normalizeFolderName, parseFolderPath, serializeFolderPath, } from "./path";
2
- export { buildFolderIndex, buildFolderTree, flattenFolderTree, type FolderIndex, type FolderNode, type FolderRemap, planFolderDelete, planFolderMove, planFolderRename, } from "./tree";
3
- export { type FolderDeleteMode, type FolderTarget, folderMoveTargets, planFolderMergeInto, } from "./actions";
1
+ export { FOLDER_PATH_DELIMITER, FOLDER_PATH_ESCAPE, folderDescendantPrefix, folderPathKey, formatFolderPath, isFolderPathUnder, isValidFolderName, normalizeFolderName, parseFolderPath, serializeFolderPath, } from "./path.js";
2
+ export { buildFolderIndex, buildFolderTree, flattenFolderTree, type FolderIndex, type FolderNode, type FolderRemap, planFolderDelete, planFolderMove, planFolderRename, } from "./tree.js";
3
+ export { type FolderDeleteMode, type FolderTarget, folderMoveTargets, planFolderMergeInto, } from "./actions.js";
@@ -15,6 +15,6 @@
15
15
  // and bulk-moved by SQL. Importing the React barrel into a Bun server to reach
16
16
  // `isFolderPathUnder` would drag `Button`/`Input` and the whole component graph into a
17
17
  // process that renders nothing. Same law as `cursedbelt-core/sharing` vs `cursedbelt/react/sharing`.
18
- export { FOLDER_PATH_DELIMITER, FOLDER_PATH_ESCAPE, folderDescendantPrefix, folderPathKey, formatFolderPath, isFolderPathUnder, isValidFolderName, normalizeFolderName, parseFolderPath, serializeFolderPath, } from "./path";
19
- export { buildFolderIndex, buildFolderTree, flattenFolderTree, planFolderDelete, planFolderMove, planFolderRename, } from "./tree";
20
- export { folderMoveTargets, planFolderMergeInto, } from "./actions";
18
+ export { FOLDER_PATH_DELIMITER, FOLDER_PATH_ESCAPE, folderDescendantPrefix, folderPathKey, formatFolderPath, isFolderPathUnder, isValidFolderName, normalizeFolderName, parseFolderPath, serializeFolderPath, } from "./path.js";
19
+ export { buildFolderIndex, buildFolderTree, flattenFolderTree, planFolderDelete, planFolderMove, planFolderRename, } from "./tree.js";
20
+ export { folderMoveTargets, planFolderMergeInto, } from "./actions.js";
@@ -10,7 +10,7 @@
10
10
  * entries and for notes' groups) and keeps the dangerous part — rewriting stored
11
11
  * items — a small, testable list of string swaps.
12
12
  */
13
- import { folderPathKey, isFolderPathUnder, normalizeFolderName, parseFolderPath, serializeFolderPath, } from "./path";
13
+ import { folderPathKey, isFolderPathUnder, normalizeFolderName, parseFolderPath, serializeFolderPath, } from "./path.js";
14
14
  /**
15
15
  * Build the nested tree from a flat collection of serialized folder-path strings
16
16
  * (one per item, duplicates fine). EVERY prefix becomes a node, so a tree with only
@@ -8,7 +8,7 @@
8
8
  * What the whole feature is, and what it is NOT, is written once in the engine's module
9
9
  * note — read that before changing anything here.
10
10
  */
11
- export { MASTER_LOCK_KDF, type MasterLockKdfParams, b64u, deriveMasterLockVerifier, isMasterLockKdfParams, newMasterLockKdfParams, unb64u, } from "./kdf";
12
- export { BASE_DELAY_MS, DEFAULT_IDLE_MS, FREE_ATTEMPTS, MAX_DELAY_MS, MAX_IDLE_MS, MIN_IDLE_MS, clampIdleMs, delayAfter, windowRemaining, } from "./policy";
13
- export { MASTER_LOCK_COOKIE, masterLockCookieName, MASTER_LOCK_HEADER, MASTER_LOCK_PATHS, MASTER_LOCK_PREFIX, MASTER_LOCK_STATE_HEADER, type MasterLockAccountSummary, type MasterLockAccountsResponse, type MasterLockAddAccountRequest, type MasterLockChangeRequest, type MasterLockEditAccountRequest, type MasterLockEnrollRequest, type MasterLockHintsResponse, type MasterLockRefusal, type MasterLockSettingsRequest, type MasterLockStatus, type MasterLockUnlockRequest, } from "./wire";
14
- export { PRESENCE_ATTRIBUTE, type PresenceMedia, isPresenceMedia, isSomebodyWatching, } from "./presence";
11
+ export { MASTER_LOCK_KDF, type MasterLockKdfParams, b64u, deriveMasterLockVerifier, isMasterLockKdfParams, newMasterLockKdfParams, unb64u, } from "./kdf.js";
12
+ export { BASE_DELAY_MS, DEFAULT_IDLE_MS, FREE_ATTEMPTS, MAX_DELAY_MS, MAX_IDLE_MS, MIN_IDLE_MS, clampIdleMs, delayAfter, windowRemaining, } from "./policy.js";
13
+ export { MASTER_LOCK_COOKIE, masterLockCookieName, MASTER_LOCK_HEADER, MASTER_LOCK_PATHS, MASTER_LOCK_PREFIX, MASTER_LOCK_STATE_HEADER, type MasterLockAccountSummary, type MasterLockAccountsResponse, type MasterLockAddAccountRequest, type MasterLockChangeRequest, type MasterLockEditAccountRequest, type MasterLockEnrollRequest, type MasterLockHintsResponse, type MasterLockRefusal, type MasterLockSettingsRequest, type MasterLockStatus, type MasterLockUnlockRequest, } from "./wire.js";
14
+ export { PRESENCE_ATTRIBUTE, type PresenceMedia, isPresenceMedia, isSomebodyWatching, } from "./presence.js";
@@ -8,7 +8,7 @@
8
8
  * What the whole feature is, and what it is NOT, is written once in the engine's module
9
9
  * note — read that before changing anything here.
10
10
  */
11
- export { MASTER_LOCK_KDF, b64u, deriveMasterLockVerifier, isMasterLockKdfParams, newMasterLockKdfParams, unb64u, } from "./kdf";
12
- export { BASE_DELAY_MS, DEFAULT_IDLE_MS, FREE_ATTEMPTS, MAX_DELAY_MS, MAX_IDLE_MS, MIN_IDLE_MS, clampIdleMs, delayAfter, windowRemaining, } from "./policy";
13
- export { MASTER_LOCK_COOKIE, masterLockCookieName, MASTER_LOCK_HEADER, MASTER_LOCK_PATHS, MASTER_LOCK_PREFIX, MASTER_LOCK_STATE_HEADER, } from "./wire";
14
- export { PRESENCE_ATTRIBUTE, isPresenceMedia, isSomebodyWatching, } from "./presence";
11
+ export { MASTER_LOCK_KDF, b64u, deriveMasterLockVerifier, isMasterLockKdfParams, newMasterLockKdfParams, unb64u, } from "./kdf.js";
12
+ export { BASE_DELAY_MS, DEFAULT_IDLE_MS, FREE_ATTEMPTS, MAX_DELAY_MS, MAX_IDLE_MS, MIN_IDLE_MS, clampIdleMs, delayAfter, windowRemaining, } from "./policy.js";
13
+ export { MASTER_LOCK_COOKIE, masterLockCookieName, MASTER_LOCK_HEADER, MASTER_LOCK_PATHS, MASTER_LOCK_PREFIX, MASTER_LOCK_STATE_HEADER, } from "./wire.js";
14
+ export { PRESENCE_ATTRIBUTE, isPresenceMedia, isSomebodyWatching, } from "./presence.js";
@@ -4,7 +4,7 @@
4
4
  * One module both halves import, so a rename cannot make the browser talk to a route that
5
5
  * no longer exists.
6
6
  */
7
- import type { MasterLockKdfParams } from "./kdf";
7
+ import type { MasterLockKdfParams } from "./kdf.js";
8
8
  /**
9
9
  * The mount prefix. Everything under it is reachable while LOCKED (it is how you unlock);
10
10
  * everything else on the origin is not.
@@ -1,4 +1,4 @@
1
- import type { VideoFormat, VideoProgressEvent, VideoQuality, VideoTrimSpec } from './types';
1
+ import type { VideoFormat, VideoProgressEvent, VideoQuality, VideoTrimSpec } from './types.js';
2
2
  /** Codec + rate-control plan for an output container at a quality preset. */
3
3
  export interface VideoCodecPlan {
4
4
  videoCodec: string;
@@ -1,4 +1,4 @@
1
- import type { ImageOutputFormat } from './types';
1
+ import type { ImageOutputFormat } from './types.js';
2
2
  export declare const IMAGE_OUTPUT_FORMATS: readonly ImageOutputFormat[];
3
3
  export declare function imageFormatToMime(format: ImageOutputFormat): string;
4
4
  export declare function imageFormatToExtension(format: ImageOutputFormat): string;
@@ -1,12 +1,12 @@
1
- export { type FloodFillBGOptions, floodFillBGS } from './bgRemoval';
2
- export { buildProbeArgs, buildThumbnailArgs, buildTrimArgs, ffmpegTimeToSeconds, type ProbeResult, parseFfmpegProgress, parseFfprobe, type VideoCodecPlan, videoCodecPlan, } from './ffmpeg';
3
- export { clampQuality, IMAGE_OUTPUT_FORMATS, imageFormatToExtension, imageFormatToMime, mimeToImageFormat, pickThumbnailFormat, } from './format';
4
- export { isAudioItem, isImageItem, isVideoItem, mediaItemAspect, mediaItemDimensions, } from './mediaItem';
5
- export { buildAtempoChain, buildTimelineRenderPlan, DEFAULT_NORMALIZE, type FfmpegCommand, type NormalizeSpec, type RenderPlanFile, speedFilters, type TimelineRenderOptions, type TimelineRenderPlan, } from './renderPlan';
6
- export { closeMask, createRegionSegmenter, DEFAULT_CLOSE_RADIUS, DEFAULT_TOLERANCE, dilateMask, downscaleRgba, erodeMask, fitWorkingSize, type ImageSegmenter, type SegmentOptions, type SegmentPrompt, type SegmentSource, sensitivityToTolerance, upscaleMask, } from './segment';
7
- export { formatBytes } from './size';
8
- export { type ClampTrimOptions, clampTrimRange, formatTimecode, type TrimRange, trimDurationSeconds, } from './trim';
9
- export type { GalleryMedia, GalleryPhoto, ImageOperation, ImageOutputFormat, ImagePipelineSpec, MediaItem, MediaKind, MediaKindFilter, PlayableVideo, ProcessingStatus, VideoFormat, VideoProgressEvent, VideoQuality, VideoTrimSpec, } from './types';
10
- export { DEFAULT_UPLOAD_CHUNK_SIZE, MAX_SINGLE_UPLOAD_BYTES } from './uploadLimits';
11
- export { assertValidImagePipelineSpec, assertValidVideoTrimSpec, type ValidationResult, validateImagePipelineSpec, validateVideoTrimSpec, } from './validate';
12
- export { cutListProblem, cutSeconds, cutTimecode, keptSeconds, keptSegments, MIN_CUT_SECONDS, normalizeCuts, type VideoCut, } from './videoCuts';
1
+ export { type FloodFillBGOptions, floodFillBGS } from './bgRemoval.js';
2
+ export { buildProbeArgs, buildThumbnailArgs, buildTrimArgs, ffmpegTimeToSeconds, type ProbeResult, parseFfmpegProgress, parseFfprobe, type VideoCodecPlan, videoCodecPlan, } from './ffmpeg.js';
3
+ export { clampQuality, IMAGE_OUTPUT_FORMATS, imageFormatToExtension, imageFormatToMime, mimeToImageFormat, pickThumbnailFormat, } from './format.js';
4
+ export { isAudioItem, isImageItem, isVideoItem, mediaItemAspect, mediaItemDimensions, } from './mediaItem.js';
5
+ export { buildAtempoChain, buildTimelineRenderPlan, DEFAULT_NORMALIZE, type FfmpegCommand, type NormalizeSpec, type RenderPlanFile, speedFilters, type TimelineRenderOptions, type TimelineRenderPlan, } from './renderPlan.js';
6
+ export { closeMask, createRegionSegmenter, DEFAULT_CLOSE_RADIUS, DEFAULT_TOLERANCE, dilateMask, downscaleRgba, erodeMask, fitWorkingSize, type ImageSegmenter, type SegmentOptions, type SegmentPrompt, type SegmentSource, sensitivityToTolerance, upscaleMask, } from './segment.js';
7
+ export { formatBytes } from './size.js';
8
+ export { type ClampTrimOptions, clampTrimRange, formatTimecode, type TrimRange, trimDurationSeconds, } from './trim.js';
9
+ export type { GalleryMedia, GalleryPhoto, ImageOperation, ImageOutputFormat, ImagePipelineSpec, MediaItem, MediaKind, MediaKindFilter, PlayableVideo, ProcessingStatus, VideoFormat, VideoProgressEvent, VideoQuality, VideoTrimSpec, } from './types.js';
10
+ export { DEFAULT_UPLOAD_CHUNK_SIZE, MAX_SINGLE_UPLOAD_BYTES } from './uploadLimits.js';
11
+ export { assertValidImagePipelineSpec, assertValidVideoTrimSpec, type ValidationResult, validateImagePipelineSpec, validateVideoTrimSpec, } from './validate.js';
12
+ export { cutListProblem, cutSeconds, cutTimecode, keptSeconds, keptSegments, MIN_CUT_SECONDS, normalizeCuts, type VideoCut, } from './videoCuts.js';
@@ -2,17 +2,17 @@
2
2
  // the Bun server adapter (`src/server/media-bun`), the React components
3
3
  // (`cursedbelt/react/media`), and any consuming app/test. No React, no Bun, no npm
4
4
  // dep — the same module loads identically everywhere.
5
- export { floodFillBGS } from './bgRemoval';
6
- export { buildProbeArgs, buildThumbnailArgs, buildTrimArgs, ffmpegTimeToSeconds, parseFfmpegProgress, parseFfprobe, videoCodecPlan, } from './ffmpeg';
7
- export { clampQuality, IMAGE_OUTPUT_FORMATS, imageFormatToExtension, imageFormatToMime, mimeToImageFormat, pickThumbnailFormat, } from './format';
8
- export { isAudioItem, isImageItem, isVideoItem, mediaItemAspect, mediaItemDimensions, } from './mediaItem';
9
- export { buildAtempoChain, buildTimelineRenderPlan, DEFAULT_NORMALIZE, speedFilters, } from './renderPlan';
10
- export { closeMask, createRegionSegmenter, DEFAULT_CLOSE_RADIUS, DEFAULT_TOLERANCE, dilateMask, downscaleRgba, erodeMask, fitWorkingSize, sensitivityToTolerance, upscaleMask, } from './segment';
11
- export { formatBytes } from './size';
12
- export { clampTrimRange, formatTimecode, trimDurationSeconds, } from './trim';
13
- export { DEFAULT_UPLOAD_CHUNK_SIZE, MAX_SINGLE_UPLOAD_BYTES } from './uploadLimits';
14
- export { assertValidImagePipelineSpec, assertValidVideoTrimSpec, validateImagePipelineSpec, validateVideoTrimSpec, } from './validate';
5
+ export { floodFillBGS } from './bgRemoval.js';
6
+ export { buildProbeArgs, buildThumbnailArgs, buildTrimArgs, ffmpegTimeToSeconds, parseFfmpegProgress, parseFfprobe, videoCodecPlan, } from './ffmpeg.js';
7
+ export { clampQuality, IMAGE_OUTPUT_FORMATS, imageFormatToExtension, imageFormatToMime, mimeToImageFormat, pickThumbnailFormat, } from './format.js';
8
+ export { isAudioItem, isImageItem, isVideoItem, mediaItemAspect, mediaItemDimensions, } from './mediaItem.js';
9
+ export { buildAtempoChain, buildTimelineRenderPlan, DEFAULT_NORMALIZE, speedFilters, } from './renderPlan.js';
10
+ export { closeMask, createRegionSegmenter, DEFAULT_CLOSE_RADIUS, DEFAULT_TOLERANCE, dilateMask, downscaleRgba, erodeMask, fitWorkingSize, sensitivityToTolerance, upscaleMask, } from './segment.js';
11
+ export { formatBytes } from './size.js';
12
+ export { clampTrimRange, formatTimecode, trimDurationSeconds, } from './trim.js';
13
+ export { DEFAULT_UPLOAD_CHUNK_SIZE, MAX_SINGLE_UPLOAD_BYTES } from './uploadLimits.js';
14
+ export { assertValidImagePipelineSpec, assertValidVideoTrimSpec, validateImagePipelineSpec, validateVideoTrimSpec, } from './validate.js';
15
15
  // Marking sections of a video to CUT OUT. Framework-free on purpose: the dialog
16
16
  // (`react/media/VideoCutEditor`), the app route that refuses an unexecutable list and the
17
17
  // binary-server job that runs it all read the same rules from here.
18
- export { cutListProblem, cutSeconds, cutTimecode, keptSeconds, keptSegments, MIN_CUT_SECONDS, normalizeCuts, } from './videoCuts';
18
+ export { cutListProblem, cutSeconds, cutTimecode, keptSeconds, keptSegments, MIN_CUT_SECONDS, normalizeCuts, } from './videoCuts.js';
@@ -1,4 +1,4 @@
1
- import type { MediaItem } from './types';
1
+ import type { MediaItem } from './types.js';
2
2
  export declare const isImageItem: (item: MediaItem) => boolean;
3
3
  export declare const isVideoItem: (item: MediaItem) => boolean;
4
4
  export declare const isAudioItem: (item: MediaItem) => boolean;
@@ -1,5 +1,5 @@
1
1
  import { type TimelineTrack } from 'cwip/timeline';
2
- import type { VideoFormat, VideoQuality } from './types';
2
+ import type { VideoFormat, VideoQuality } from './types.js';
3
3
  /** An auxiliary text file the executor writes before running the commands. */
4
4
  export interface RenderPlanFile {
5
5
  name: string;
@@ -15,7 +15,7 @@
15
15
  // end-to-end in timeline order, so close gaps before exporting if you want cuts
16
16
  // where the gaps were. Audio clip offsets are their absolute `timelineStart`.
17
17
  import { clipDuration, roundSeconds } from 'cwip/timeline';
18
- import { videoCodecPlan } from './ffmpeg';
18
+ import { videoCodecPlan } from './ffmpeg.js';
19
19
  /** 720p30 keeps in-browser (wasm) export times tolerable; hosts can raise it. */
20
20
  export const DEFAULT_NORMALIZE = {
21
21
  width: 1280,
@@ -1,4 +1,4 @@
1
- import type { ImagePipelineSpec, VideoTrimSpec } from './types';
1
+ import type { ImagePipelineSpec, VideoTrimSpec } from './types.js';
2
2
  export interface ValidationResult {
3
3
  valid: boolean;
4
4
  errors: string[];
@@ -1,7 +1,7 @@
1
1
  // Pure spec validation — catches a malformed pipeline/trim spec BEFORE it reaches
2
2
  // sharp or ffmpeg (where the failure mode is an opaque native error). No deps;
3
3
  // `validate*` returns a collected error list, `assert*` throws on the first problem.
4
- import { IMAGE_OUTPUT_FORMATS } from './format';
4
+ import { IMAGE_OUTPUT_FORMATS } from './format.js';
5
5
  function ok(errors) {
6
6
  return { valid: errors.length === 0, errors };
7
7
  }
@@ -1,4 +1,4 @@
1
- import type { NavigationModel } from './navigationModel';
1
+ import type { NavigationModel } from './navigationModel.js';
2
2
  /**
3
3
  * Build the coverage assertion for `model` against `allRoutePaths` (the app's full
4
4
  * route list, e.g. derived from `routes.tsx`). The returned `() => void` throws —