cursedbelt-core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/dist/client/wsWire.d.ts +49 -0
- package/dist/client/wsWire.js +57 -0
- package/dist/config/appConfig.d.ts +361 -0
- package/dist/config/appConfig.js +308 -0
- package/dist/core/activity/index.d.ts +8 -0
- package/dist/core/activity/index.js +7 -0
- package/dist/core/activity/model.d.ts +115 -0
- package/dist/core/activity/model.js +63 -0
- package/dist/core/analytics/index.d.ts +18 -0
- package/dist/core/analytics/index.js +18 -0
- package/dist/core/analytics/range.d.ts +186 -0
- package/dist/core/analytics/range.js +271 -0
- package/dist/core/analytics/schedule.d.ts +301 -0
- package/dist/core/analytics/schedule.js +372 -0
- package/dist/core/category/index.d.ts +3 -0
- package/dist/core/category/index.js +6 -0
- package/dist/core/category/memoryStore.d.ts +21 -0
- package/dist/core/category/memoryStore.js +97 -0
- package/dist/core/category/tree.d.ts +26 -0
- package/dist/core/category/tree.js +113 -0
- package/dist/core/category/types.d.ts +48 -0
- package/dist/core/category/types.js +8 -0
- package/dist/core/color.d.ts +14 -0
- package/dist/core/color.js +32 -0
- package/dist/core/colorScheme/colorSchemeStore.d.ts +70 -0
- package/dist/core/colorScheme/colorSchemeStore.js +123 -0
- package/dist/core/colorScheme/index.d.ts +7 -0
- package/dist/core/colorScheme/index.js +7 -0
- package/dist/core/domainOwners.d.ts +19 -0
- package/dist/core/domainOwners.js +15 -0
- package/dist/core/download.d.ts +14 -0
- package/dist/core/download.js +37 -0
- package/dist/core/events/ccEventBus.d.ts +44 -0
- package/dist/core/events/ccEventBus.js +70 -0
- package/dist/core/file-tree/fileTreeModel.d.ts +276 -0
- package/dist/core/file-tree/fileTreeModel.js +312 -0
- package/dist/core/folder-tree/actions.d.ts +84 -0
- package/dist/core/folder-tree/actions.js +119 -0
- package/dist/core/folder-tree/index.d.ts +3 -0
- package/dist/core/folder-tree/index.js +20 -0
- package/dist/core/folder-tree/path.d.ts +76 -0
- package/dist/core/folder-tree/path.js +146 -0
- package/dist/core/folder-tree/tree.d.ts +86 -0
- package/dist/core/folder-tree/tree.js +184 -0
- package/dist/core/layout/index.d.ts +1 -0
- package/dist/core/layout/index.js +7 -0
- package/dist/core/master-lock/index.d.ts +14 -0
- package/dist/core/master-lock/index.js +14 -0
- package/dist/core/master-lock/kdf.d.ts +63 -0
- package/dist/core/master-lock/kdf.js +117 -0
- package/dist/core/master-lock/policy.d.ts +32 -0
- package/dist/core/master-lock/policy.js +52 -0
- package/dist/core/master-lock/presence.d.ts +52 -0
- package/dist/core/master-lock/presence.js +61 -0
- package/dist/core/master-lock/wire.d.ts +139 -0
- package/dist/core/master-lock/wire.js +81 -0
- package/dist/core/media/bgRemoval.d.ts +27 -0
- package/dist/core/media/bgRemoval.js +101 -0
- package/dist/core/media/ffmpeg.d.ts +57 -0
- package/dist/core/media/ffmpeg.js +126 -0
- package/dist/core/media/format.d.ts +20 -0
- package/dist/core/media/format.js +54 -0
- package/dist/core/media/index.d.ts +12 -0
- package/dist/core/media/index.js +18 -0
- package/dist/core/media/mediaItem.d.ts +15 -0
- package/dist/core/media/mediaItem.js +34 -0
- package/dist/core/media/renderPlan.d.ts +63 -0
- package/dist/core/media/renderPlan.js +232 -0
- package/dist/core/media/segment.d.ts +79 -0
- package/dist/core/media/segment.js +247 -0
- package/dist/core/media/size.d.ts +1 -0
- package/dist/core/media/size.js +3 -0
- package/dist/core/media/trim.d.ts +21 -0
- package/dist/core/media/trim.js +40 -0
- package/dist/core/media/types.d.ts +148 -0
- package/dist/core/media/types.js +11 -0
- package/dist/core/media/uploadLimits.d.ts +7 -0
- package/dist/core/media/uploadLimits.js +16 -0
- package/dist/core/media/validate.d.ts +9 -0
- package/dist/core/media/validate.js +121 -0
- package/dist/core/media/videoCuts.d.ts +152 -0
- package/dist/core/media/videoCuts.js +284 -0
- package/dist/core/navigation/breadcrumbCompat.d.ts +24 -0
- package/dist/core/navigation/breadcrumbCompat.js +21 -0
- package/dist/core/navigation/coverageTest.d.ts +24 -0
- package/dist/core/navigation/coverageTest.js +48 -0
- package/dist/core/navigation/index.d.ts +6 -0
- package/dist/core/navigation/index.js +10 -0
- package/dist/core/navigation/migrateNavPreferences.d.ts +55 -0
- package/dist/core/navigation/migrateNavPreferences.js +63 -0
- package/dist/core/navigation/navigationModel.d.ts +165 -0
- package/dist/core/navigation/navigationModel.js +14 -0
- package/dist/core/navigation/navigationSelectors.d.ts +63 -0
- package/dist/core/navigation/navigationSelectors.js +167 -0
- package/dist/core/navigation/searchCatalogue.d.ts +23 -0
- package/dist/core/navigation/searchCatalogue.js +76 -0
- package/dist/core/net/reconnectSchedule.d.ts +46 -0
- package/dist/core/net/reconnectSchedule.js +38 -0
- package/dist/core/palette/ShortcutRegistry.d.ts +33 -0
- package/dist/core/palette/ShortcutRegistry.js +52 -0
- package/dist/core/palette/fuzzyRank.d.ts +18 -0
- package/dist/core/palette/fuzzyRank.js +73 -0
- package/dist/core/palette/hotkeys.d.ts +28 -0
- package/dist/core/palette/hotkeys.js +118 -0
- package/dist/core/palette/index.d.ts +3 -0
- package/dist/core/palette/index.js +8 -0
- package/dist/core/schema/projection.d.ts +84 -0
- package/dist/core/schema/projection.js +184 -0
- package/dist/core/session/claims.d.ts +145 -0
- package/dist/core/session/claims.js +157 -0
- package/dist/core/sharing/index.d.ts +5 -0
- package/dist/core/sharing/index.js +4 -0
- package/dist/core/sharing/model.d.ts +195 -0
- package/dist/core/sharing/model.js +150 -0
- package/dist/core/site/siteCopy.d.ts +92 -0
- package/dist/core/site/siteCopy.js +91 -0
- package/dist/core/slots.d.ts +22 -0
- package/dist/core/slots.js +31 -0
- package/dist/core/styles/constructableSheet.d.ts +51 -0
- package/dist/core/styles/constructableSheet.js +80 -0
- package/dist/core/tokens/registry.d.ts +119 -0
- package/dist/core/tokens/registry.js +447 -0
- package/dist/core/uiScale/index.d.ts +8 -0
- package/dist/core/uiScale/index.js +8 -0
- package/dist/core/uiScale/scale.d.ts +36 -0
- package/dist/core/uiScale/scale.js +47 -0
- package/dist/core/uiScale/uiScaleStore.d.ts +47 -0
- package/dist/core/uiScale/uiScaleStore.js +63 -0
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.d.ts +1 -0
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.js +23 -0
- package/dist/core/wire/index.d.ts +17 -0
- package/dist/core/wire/index.js +17 -0
- package/dist/core/wire/ingress.d.ts +44 -0
- package/dist/core/wire/ingress.js +134 -0
- package/dist/core/wire/marshalWorker.d.ts +70 -0
- package/dist/core/wire/marshalWorker.js +273 -0
- package/dist/core/wire/marshalling.d.ts +122 -0
- package/dist/core/wire/marshalling.js +126 -0
- package/dist/core/wire/wireHub.d.ts +80 -0
- package/dist/core/wire/wireHub.js +116 -0
- package/dist/ctgr/base64url.d.ts +8 -0
- package/dist/ctgr/base64url.js +39 -0
- package/dist/ctgr/chunkAccumulator.d.ts +214 -0
- package/dist/ctgr/chunkAccumulator.js +307 -0
- package/dist/ctgr/chunkStore.d.ts +24 -0
- package/dist/ctgr/chunkStore.js +125 -0
- package/dist/ctgr/codec.d.ts +44 -0
- package/dist/ctgr/codec.js +153 -0
- package/dist/ctgr/compress.d.ts +2 -0
- package/dist/ctgr/compress.js +20 -0
- package/dist/ctgr/crc32.d.ts +2 -0
- package/dist/ctgr/crc32.js +23 -0
- package/dist/ctgr/digest.d.ts +1 -0
- package/dist/ctgr/digest.js +15 -0
- package/dist/ctgr/index.d.ts +9 -0
- package/dist/ctgr/index.js +17 -0
- package/dist/ctgr/types.d.ts +64 -0
- package/dist/ctgr/types.js +21 -0
- package/dist/ctgr/v0compat.d.ts +27 -0
- package/dist/ctgr/v0compat.js +128 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +17 -0
- package/dist/shared/errors.d.ts +101 -0
- package/dist/shared/errors.js +99 -0
- package/dist/testing/bytes.d.ts +32 -0
- package/dist/testing/bytes.js +33 -0
- package/dist/testing/domInspect.d.ts +59 -0
- package/dist/testing/domInspect.js +115 -0
- package/dist/testing/fakeFetch.d.ts +23 -0
- package/dist/testing/fakeFetch.js +21 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.js +11 -0
- package/package.json +373 -0
- package/src/client/wsWire.ts +83 -0
- package/src/config/appConfig.spec.ts +158 -0
- package/src/config/appConfig.ts +372 -0
- package/src/core/activity/index.ts +21 -0
- package/src/core/activity/model.ts +160 -0
- package/src/core/analytics/index.ts +18 -0
- package/src/core/analytics/range.spec.ts +280 -0
- package/src/core/analytics/range.ts +383 -0
- package/src/core/analytics/schedule.spec.ts +311 -0
- package/src/core/analytics/schedule.ts +624 -0
- package/src/core/category/category.spec.ts +144 -0
- package/src/core/category/index.ts +19 -0
- package/src/core/category/memoryStore.ts +104 -0
- package/src/core/category/tree.ts +118 -0
- package/src/core/category/types.ts +59 -0
- package/src/core/color.spec.ts +35 -0
- package/src/core/color.ts +35 -0
- package/src/core/colorScheme/colorScheme.spec.ts +127 -0
- package/src/core/colorScheme/colorSchemeStore.ts +184 -0
- package/src/core/colorScheme/index.ts +23 -0
- package/src/core/domainOwners.ts +32 -0
- package/src/core/download.spec.ts +38 -0
- package/src/core/download.ts +35 -0
- package/src/core/events/ccEventBus.spec.ts +90 -0
- package/src/core/events/ccEventBus.ts +120 -0
- package/src/core/file-tree/fileTreeModel.spec.ts +462 -0
- package/src/core/file-tree/fileTreeModel.ts +511 -0
- package/src/core/folder-tree/actions.spec.ts +103 -0
- package/src/core/folder-tree/actions.ts +153 -0
- package/src/core/folder-tree/index.ts +46 -0
- package/src/core/folder-tree/path.spec.ts +113 -0
- package/src/core/folder-tree/path.ts +148 -0
- package/src/core/folder-tree/tree.spec.ts +157 -0
- package/src/core/folder-tree/tree.ts +244 -0
- package/src/core/layout/index.ts +7 -0
- package/src/core/master-lock/index.ts +50 -0
- package/src/core/master-lock/kdf.spec.ts +95 -0
- package/src/core/master-lock/kdf.ts +145 -0
- package/src/core/master-lock/policy.ts +55 -0
- package/src/core/master-lock/presence.spec.ts +73 -0
- package/src/core/master-lock/presence.ts +71 -0
- package/src/core/master-lock/wire.ts +155 -0
- package/src/core/media/bgRemoval.spec.ts +120 -0
- package/src/core/media/bgRemoval.ts +123 -0
- package/src/core/media/ffmpeg.spec.ts +135 -0
- package/src/core/media/ffmpeg.ts +162 -0
- package/src/core/media/format.spec.ts +41 -0
- package/src/core/media/format.ts +58 -0
- package/src/core/media/index.ts +105 -0
- package/src/core/media/mediaItem.spec.ts +46 -0
- package/src/core/media/mediaItem.ts +40 -0
- package/src/core/media/renderPlan.spec.ts +140 -0
- package/src/core/media/renderPlan.ts +318 -0
- package/src/core/media/segment.spec.ts +159 -0
- package/src/core/media/segment.ts +352 -0
- package/src/core/media/size.spec.ts +19 -0
- package/src/core/media/size.ts +3 -0
- package/src/core/media/trim.spec.ts +75 -0
- package/src/core/media/trim.ts +60 -0
- package/src/core/media/types.ts +147 -0
- package/src/core/media/uploadLimits.ts +18 -0
- package/src/core/media/validate.spec.ts +106 -0
- package/src/core/media/validate.ts +131 -0
- package/src/core/media/videoCuts.spec.ts +327 -0
- package/src/core/media/videoCuts.ts +355 -0
- package/src/core/navigation/breadcrumbCompat.ts +44 -0
- package/src/core/navigation/coverageTest.ts +54 -0
- package/src/core/navigation/index.ts +50 -0
- package/src/core/navigation/migrateNavPreferences.ts +119 -0
- package/src/core/navigation/navigation.spec.ts +388 -0
- package/src/core/navigation/navigationModel.ts +185 -0
- package/src/core/navigation/navigationSelectors.ts +206 -0
- package/src/core/navigation/searchCatalogue.ts +89 -0
- package/src/core/net/reconnectSchedule.spec.ts +49 -0
- package/src/core/net/reconnectSchedule.ts +78 -0
- package/src/core/palette/ShortcutRegistry.ts +69 -0
- package/src/core/palette/fuzzyRank.ts +78 -0
- package/src/core/palette/hotkeys.ts +132 -0
- package/src/core/palette/index.ts +22 -0
- package/src/core/palette/palette.spec.ts +160 -0
- package/src/core/schema/projection.spec.ts +122 -0
- package/src/core/schema/projection.ts +225 -0
- package/src/core/session/claims.ts +181 -0
- package/src/core/sharing/index.ts +33 -0
- package/src/core/sharing/model.spec.ts +104 -0
- package/src/core/sharing/model.ts +296 -0
- package/src/core/site/siteCopy.ts +115 -0
- package/src/core/slots.ts +38 -0
- package/src/core/styles/constructableSheet.ts +86 -0
- package/src/core/tokens/registry.spec.ts +160 -0
- package/src/core/tokens/registry.ts +536 -0
- package/src/core/uiScale/index.ts +32 -0
- package/src/core/uiScale/scale.ts +63 -0
- package/src/core/uiScale/uiScale.spec.ts +77 -0
- package/src/core/uiScale/uiScaleStore.ts +100 -0
- package/src/core/wire/__fixtures__/rowsMarshalWorker.ts +24 -0
- package/src/core/wire/index.ts +56 -0
- package/src/core/wire/ingress.spec.ts +276 -0
- package/src/core/wire/ingress.ts +182 -0
- package/src/core/wire/marshalWorker.spec.ts +196 -0
- package/src/core/wire/marshalWorker.ts +361 -0
- package/src/core/wire/marshalling.spec.ts +139 -0
- package/src/core/wire/marshalling.ts +238 -0
- package/src/core/wire/wireHub.spec.ts +137 -0
- package/src/core/wire/wireHub.ts +213 -0
- package/src/ctgr/base64url.ts +44 -0
- package/src/ctgr/chunkAccumulator.ts +403 -0
- package/src/ctgr/chunkStore.spec.ts +101 -0
- package/src/ctgr/chunkStore.ts +168 -0
- package/src/ctgr/codec.spec.ts +208 -0
- package/src/ctgr/codec.ts +202 -0
- package/src/ctgr/compress.ts +21 -0
- package/src/ctgr/crc32.ts +23 -0
- package/src/ctgr/digest.ts +14 -0
- package/src/ctgr/index.ts +39 -0
- package/src/ctgr/types.ts +80 -0
- package/src/ctgr/v0compat.spec.ts +73 -0
- package/src/ctgr/v0compat.ts +156 -0
- package/src/declaredDepsAreImported.spec.ts +87 -0
- package/src/index.ts +25 -0
- package/src/leafSubpathsImportNothing.spec.ts +287 -0
- package/src/lockfileIsTracked.spec.ts +50 -0
- package/src/namedSubpathsResolve.spec.ts +122 -0
- package/src/noPathDeps.spec.ts +24 -0
- package/src/publishShape.spec.ts +89 -0
- package/src/shared/errors.ts +153 -0
- package/src/skippedTestsAreAnswered.spec.ts +221 -0
- package/src/testTmpRoot.spec.ts +102 -0
- package/src/testing/bytes.ts +38 -0
- package/src/testing/domInspect.spec.ts +79 -0
- package/src/testing/domInspect.ts +127 -0
- package/src/testing/fakeFetch.ts +26 -0
- package/src/testing/index.ts +11 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The numbers — how long an unlock survives being ignored, and how hard it is to guess at.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions with no clock and no I/O of their own, so every edge of the schedule is a
|
|
5
|
+
* unit test rather than a wait. The engine in `../../server/master-lock/` supplies `now`.
|
|
6
|
+
*/
|
|
7
|
+
/** The owner's default: *"default to 5 minutes if I haven't configured it"*. */
|
|
8
|
+
export declare const DEFAULT_IDLE_MS: number;
|
|
9
|
+
/** Short enough to be a real lock, long enough that typing a password is not the task. */
|
|
10
|
+
export declare const MIN_IDLE_MS = 30000;
|
|
11
|
+
/** A day. Past this the timer is not what is protecting anything — the manual lock is. */
|
|
12
|
+
export declare const MAX_IDLE_MS: number;
|
|
13
|
+
/** Clamp a submitted idle timeout into the usable band. A value that is not a finite
|
|
14
|
+
* number at all falls back to the default rather than to a bound: a corrupt record must
|
|
15
|
+
* not silently become "locks in 30 seconds" or "never locks". */
|
|
16
|
+
export declare function clampIdleMs(value: unknown): number;
|
|
17
|
+
/** Consecutive failures that cost nothing. */
|
|
18
|
+
export declare const FREE_ATTEMPTS = 5;
|
|
19
|
+
/** The first enforced wait once those are spent. Doubles thereafter. */
|
|
20
|
+
export declare const BASE_DELAY_MS = 30000;
|
|
21
|
+
/** The ceiling on that wait. */
|
|
22
|
+
export declare const MAX_DELAY_MS: number;
|
|
23
|
+
/**
|
|
24
|
+
* How long must pass after `failures` consecutive failures before another attempt is
|
|
25
|
+
* evaluated. `failures` includes the one that just happened.
|
|
26
|
+
*
|
|
27
|
+
* ≤5 → 0 · 6 → 30s · 7 → 60s · 8 → 2m · … · ≥11 → 15m (capped)
|
|
28
|
+
*/
|
|
29
|
+
export declare function delayAfter(failures: number): number;
|
|
30
|
+
/** Milliseconds still to wait, given the failure count, when the last one happened, and
|
|
31
|
+
* now. `0` means "evaluate it". */
|
|
32
|
+
export declare function windowRemaining(failures: number, lastFailureAt: number, now: number): number;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The numbers — how long an unlock survives being ignored, and how hard it is to guess at.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions with no clock and no I/O of their own, so every edge of the schedule is a
|
|
5
|
+
* unit test rather than a wait. The engine in `../../server/master-lock/` supplies `now`.
|
|
6
|
+
*/
|
|
7
|
+
/** The owner's default: *"default to 5 minutes if I haven't configured it"*. */
|
|
8
|
+
export const DEFAULT_IDLE_MS = 5 * 60_000;
|
|
9
|
+
/** Short enough to be a real lock, long enough that typing a password is not the task. */
|
|
10
|
+
export const MIN_IDLE_MS = 30_000;
|
|
11
|
+
/** A day. Past this the timer is not what is protecting anything — the manual lock is. */
|
|
12
|
+
export const MAX_IDLE_MS = 24 * 60 * 60_000;
|
|
13
|
+
/** Clamp a submitted idle timeout into the usable band. A value that is not a finite
|
|
14
|
+
* number at all falls back to the default rather than to a bound: a corrupt record must
|
|
15
|
+
* not silently become "locks in 30 seconds" or "never locks". */
|
|
16
|
+
export function clampIdleMs(value) {
|
|
17
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
18
|
+
return DEFAULT_IDLE_MS;
|
|
19
|
+
return Math.min(MAX_IDLE_MS, Math.max(MIN_IDLE_MS, Math.round(value)));
|
|
20
|
+
}
|
|
21
|
+
// ── The guess throttle ──────────────────────────────────────────────────────
|
|
22
|
+
// The same schedule `apps/vault`'s unlock guard implements, and for the same reason: the
|
|
23
|
+
// owner mistypes, an attacker iterates. Never a lockout — the owner locking themselves out
|
|
24
|
+
// of their own laptop is a worse outcome than a slow attacker.
|
|
25
|
+
/** Consecutive failures that cost nothing. */
|
|
26
|
+
export const FREE_ATTEMPTS = 5;
|
|
27
|
+
/** The first enforced wait once those are spent. Doubles thereafter. */
|
|
28
|
+
export const BASE_DELAY_MS = 30_000;
|
|
29
|
+
/** The ceiling on that wait. */
|
|
30
|
+
export const MAX_DELAY_MS = 15 * 60_000;
|
|
31
|
+
/**
|
|
32
|
+
* How long must pass after `failures` consecutive failures before another attempt is
|
|
33
|
+
* evaluated. `failures` includes the one that just happened.
|
|
34
|
+
*
|
|
35
|
+
* ≤5 → 0 · 6 → 30s · 7 → 60s · 8 → 2m · … · ≥11 → 15m (capped)
|
|
36
|
+
*/
|
|
37
|
+
export function delayAfter(failures) {
|
|
38
|
+
if (failures <= FREE_ATTEMPTS)
|
|
39
|
+
return 0;
|
|
40
|
+
// Clamp the EXPONENT, not the product: a client that keeps hammering must not shift
|
|
41
|
+
// past a finite number into Infinity.
|
|
42
|
+
const exponent = Math.min(failures - FREE_ATTEMPTS - 1, 32);
|
|
43
|
+
return Math.min(BASE_DELAY_MS * 2 ** exponent, MAX_DELAY_MS);
|
|
44
|
+
}
|
|
45
|
+
/** Milliseconds still to wait, given the failure count, when the last one happened, and
|
|
46
|
+
* now. `0` means "evaluate it". */
|
|
47
|
+
export function windowRemaining(failures, lastFailureAt, now) {
|
|
48
|
+
const delay = delayAfter(failures);
|
|
49
|
+
if (delay === 0)
|
|
50
|
+
return 0;
|
|
51
|
+
return Math.max(0, lastFailureAt + delay - now);
|
|
52
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Somebody is still here" — evidence the idle watcher accepts that is not a keypress.
|
|
3
|
+
*
|
|
4
|
+
* ── The owner's ask (2026-08-25) ────────────────────────────────────────────
|
|
5
|
+
* *"apps that lock like collections with a master password should not lock when I am
|
|
6
|
+
* watching a video even if the timer runs out."*
|
|
7
|
+
*
|
|
8
|
+
* The idle watcher is deliberately built on INTERACTION rather than on traffic — see
|
|
9
|
+
* `MasterLockProvider`'s module note, which is right and stays. Watching a film is the
|
|
10
|
+
* one activity that produces no interaction at all and is nevertheless the most
|
|
11
|
+
* attentive thing a person does with these apps. Ninety minutes of sitting still was
|
|
12
|
+
* indistinguishable from ninety minutes of an empty room, and the empty room is what
|
|
13
|
+
* the feature is for, so the wrong one won.
|
|
14
|
+
*
|
|
15
|
+
* ── 🔴 Why this is a predicate over ELEMENTS and not a flag an app sets ─────
|
|
16
|
+
* A `watching` flag would need collections, flix, music, family and every future
|
|
17
|
+
* player to remember to raise it, and to lower it on pause, on end, on unmount and on
|
|
18
|
+
* a navigation — five chances each to leave the site permanently unlockable, which is
|
|
19
|
+
* strictly worse than locking too eagerly. The DOM already knows: a `<video>` that is
|
|
20
|
+
* playing is playing. Nothing to raise, nothing to lower, and a player that unmounts
|
|
21
|
+
* takes its evidence with it.
|
|
22
|
+
*
|
|
23
|
+
* ── 🔴 Why a decorative loop is NOT presence ────────────────────────────────
|
|
24
|
+
* The failure mode that would make this a security hole is an ambient background clip
|
|
25
|
+
* — muted, looping, autoplaying, running forever with nobody in the building. That is
|
|
26
|
+
* a recognisable signature (`muted` AND `loop`) and it is refused. A film the owner is
|
|
27
|
+
* actually watching is not both at once; a muted film that loops is not something this
|
|
28
|
+
* fleet renders. Where a component knows better than the heuristic, it says so:
|
|
29
|
+
* `data-master-lock-presence="watch"` counts unconditionally, `"ignore"` never does.
|
|
30
|
+
*/
|
|
31
|
+
/** The attribute a component uses to overrule the heuristic below. */
|
|
32
|
+
export declare const PRESENCE_ATTRIBUTE = "data-master-lock-presence";
|
|
33
|
+
/** The subset of `HTMLMediaElement` this needs — declared so the predicate is
|
|
34
|
+
* testable without a DOM and cannot drift into reading anything else. */
|
|
35
|
+
export interface PresenceMedia {
|
|
36
|
+
paused: boolean;
|
|
37
|
+
ended: boolean;
|
|
38
|
+
muted: boolean;
|
|
39
|
+
loop: boolean;
|
|
40
|
+
/** Seconds. `0` for a stream that has not started, `NaN` before metadata. */
|
|
41
|
+
duration: number;
|
|
42
|
+
getAttribute(name: string): string | null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Is this one element evidence that a person is watching or listening right now?
|
|
46
|
+
*
|
|
47
|
+
* Order matters: the explicit attribute wins over everything, because a component that
|
|
48
|
+
* says "ignore me" is describing itself better than any heuristic can.
|
|
49
|
+
*/
|
|
50
|
+
export declare function isPresenceMedia(element: PresenceMedia): boolean;
|
|
51
|
+
/** Is ANY of them? The whole question the idle watcher asks each tick. */
|
|
52
|
+
export declare function isSomebodyWatching(elements: Iterable<PresenceMedia>): boolean;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Somebody is still here" — evidence the idle watcher accepts that is not a keypress.
|
|
3
|
+
*
|
|
4
|
+
* ── The owner's ask (2026-08-25) ────────────────────────────────────────────
|
|
5
|
+
* *"apps that lock like collections with a master password should not lock when I am
|
|
6
|
+
* watching a video even if the timer runs out."*
|
|
7
|
+
*
|
|
8
|
+
* The idle watcher is deliberately built on INTERACTION rather than on traffic — see
|
|
9
|
+
* `MasterLockProvider`'s module note, which is right and stays. Watching a film is the
|
|
10
|
+
* one activity that produces no interaction at all and is nevertheless the most
|
|
11
|
+
* attentive thing a person does with these apps. Ninety minutes of sitting still was
|
|
12
|
+
* indistinguishable from ninety minutes of an empty room, and the empty room is what
|
|
13
|
+
* the feature is for, so the wrong one won.
|
|
14
|
+
*
|
|
15
|
+
* ── 🔴 Why this is a predicate over ELEMENTS and not a flag an app sets ─────
|
|
16
|
+
* A `watching` flag would need collections, flix, music, family and every future
|
|
17
|
+
* player to remember to raise it, and to lower it on pause, on end, on unmount and on
|
|
18
|
+
* a navigation — five chances each to leave the site permanently unlockable, which is
|
|
19
|
+
* strictly worse than locking too eagerly. The DOM already knows: a `<video>` that is
|
|
20
|
+
* playing is playing. Nothing to raise, nothing to lower, and a player that unmounts
|
|
21
|
+
* takes its evidence with it.
|
|
22
|
+
*
|
|
23
|
+
* ── 🔴 Why a decorative loop is NOT presence ────────────────────────────────
|
|
24
|
+
* The failure mode that would make this a security hole is an ambient background clip
|
|
25
|
+
* — muted, looping, autoplaying, running forever with nobody in the building. That is
|
|
26
|
+
* a recognisable signature (`muted` AND `loop`) and it is refused. A film the owner is
|
|
27
|
+
* actually watching is not both at once; a muted film that loops is not something this
|
|
28
|
+
* fleet renders. Where a component knows better than the heuristic, it says so:
|
|
29
|
+
* `data-master-lock-presence="watch"` counts unconditionally, `"ignore"` never does.
|
|
30
|
+
*/
|
|
31
|
+
/** The attribute a component uses to overrule the heuristic below. */
|
|
32
|
+
export const PRESENCE_ATTRIBUTE = "data-master-lock-presence";
|
|
33
|
+
/**
|
|
34
|
+
* Is this one element evidence that a person is watching or listening right now?
|
|
35
|
+
*
|
|
36
|
+
* Order matters: the explicit attribute wins over everything, because a component that
|
|
37
|
+
* says "ignore me" is describing itself better than any heuristic can.
|
|
38
|
+
*/
|
|
39
|
+
export function isPresenceMedia(element) {
|
|
40
|
+
const declared = element.getAttribute(PRESENCE_ATTRIBUTE);
|
|
41
|
+
if (declared === "ignore")
|
|
42
|
+
return false;
|
|
43
|
+
if (element.paused || element.ended)
|
|
44
|
+
return false;
|
|
45
|
+
if (declared === "watch")
|
|
46
|
+
return true;
|
|
47
|
+
// The decorative-loop signature. See the module note.
|
|
48
|
+
if (element.muted && element.loop)
|
|
49
|
+
return false;
|
|
50
|
+
// A clip with no duration yet is a stream still negotiating, not a person watching.
|
|
51
|
+
// `Infinity` is a live stream, which counts.
|
|
52
|
+
return element.duration > 0;
|
|
53
|
+
}
|
|
54
|
+
/** Is ANY of them? The whole question the idle watcher asks each tick. */
|
|
55
|
+
export function isSomebodyWatching(elements) {
|
|
56
|
+
for (const element of elements) {
|
|
57
|
+
if (isPresenceMedia(element))
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wire — paths and payloads shared by the lock page, the in-app controls and the server.
|
|
3
|
+
*
|
|
4
|
+
* One module both halves import, so a rename cannot make the browser talk to a route that
|
|
5
|
+
* no longer exists.
|
|
6
|
+
*/
|
|
7
|
+
import type { MasterLockKdfParams } from "./kdf";
|
|
8
|
+
/**
|
|
9
|
+
* The mount prefix. Everything under it is reachable while LOCKED (it is how you unlock);
|
|
10
|
+
* everything else on the origin is not.
|
|
11
|
+
*
|
|
12
|
+
* `__` because a path a user could plausibly want must never collide with it: collections
|
|
13
|
+
* routes on the hash, and the binary-server inspector owns `/api`, `/raw` and `/preview`.
|
|
14
|
+
*/
|
|
15
|
+
export declare const MASTER_LOCK_PREFIX = "/__lock";
|
|
16
|
+
export declare const MASTER_LOCK_PATHS: {
|
|
17
|
+
/** The lock page itself. Also what a locked HTML request is answered with, in place. */
|
|
18
|
+
readonly page: "/__lock/";
|
|
19
|
+
readonly style: "/__lock/lock.css";
|
|
20
|
+
readonly script: "/__lock/lock.js";
|
|
21
|
+
/** Readable while locked — it carries the KDF params the browser needs to derive. */
|
|
22
|
+
readonly status: "/__lock/status";
|
|
23
|
+
readonly unlock: "/__lock/unlock";
|
|
24
|
+
/** Slides the idle window. Sent on real INTERACTION only — see the engine. */
|
|
25
|
+
readonly ping: "/__lock/ping";
|
|
26
|
+
readonly lock: "/__lock/lock";
|
|
27
|
+
readonly change: "/__lock/change";
|
|
28
|
+
readonly settings: "/__lock/settings";
|
|
29
|
+
/**
|
|
30
|
+
* Set the FIRST master password for a principal that has none.
|
|
31
|
+
*
|
|
32
|
+
* Reachable while locked, like {@link MASTER_LOCK_PATHS.unlock}, and for the same
|
|
33
|
+
* reason: it is the only way in. It is refused outright unless the lock is
|
|
34
|
+
* `enrollable` AND has no record — so it can never overwrite a password, only
|
|
35
|
+
* create the one that was never set. Rotation is `change`, which demands the
|
|
36
|
+
* current one.
|
|
37
|
+
*/
|
|
38
|
+
readonly enroll: "/__lock/enroll";
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* The base name of the cookie the unlock token rides in. Callers want
|
|
42
|
+
* {@link masterLockCookieName}, not this.
|
|
43
|
+
*
|
|
44
|
+
* 🔴 **A cookie is scoped by HOST, never by ORIGIN**, and this constant used to carry a
|
|
45
|
+
* comment claiming the opposite — *"each app is its own origin, so unlocking one never
|
|
46
|
+
* unlocks another … falls out of the browser's own rules"*. It does not. The browser
|
|
47
|
+
* ignores the PORT when deciding which cookies to send, so two locked apps on `localhost`
|
|
48
|
+
* share one jar and each unlock overwrites the other's token.
|
|
49
|
+
*
|
|
50
|
+
* Found 2026-09-11 by walking the feature in a browser, which is the only way it shows:
|
|
51
|
+
* production is fine (collections and the inspector are different hostnames), but on the
|
|
52
|
+
* Mac — where every app is `localhost:<port>` — opening the binary-server inspector left
|
|
53
|
+
* `collections` holding a token its own server had never minted. Nothing is DISCLOSED by
|
|
54
|
+
* that, because an app-wide lock reads the ambient `unlocked` state, so the site stayed
|
|
55
|
+
* exactly as visible as it already was. What broke is every route that requires
|
|
56
|
+
* `presents(req)`, and it broke SILENTLY:
|
|
57
|
+
*
|
|
58
|
+
* · `POST /__lock/lock` — "Lock this site" did nothing but reload an open page
|
|
59
|
+
* · `POST /__lock/settings` — the idle timeout could not be changed
|
|
60
|
+
* · `POST /__lock/change` — the master password could not be rotated
|
|
61
|
+
* · `POST /__lock/ping` — activity stopped sliding the idle clock
|
|
62
|
+
*
|
|
63
|
+
* i.e. three of the owner's clauses, on the machine his whole reason for asking is about.
|
|
64
|
+
* The name is now per app, so the collision cannot happen on any host.
|
|
65
|
+
*/
|
|
66
|
+
export declare const MASTER_LOCK_COOKIE = "master_lock";
|
|
67
|
+
/**
|
|
68
|
+
* The cookie name for ONE app — `master_lock` for an app that names no key, and
|
|
69
|
+
* `master_lock_<key>` for one that does.
|
|
70
|
+
*
|
|
71
|
+
* The bare name is kept as the no-key answer so an app that has not been given a key
|
|
72
|
+
* behaves exactly as it did, and so a single locked app on a host needs no migration.
|
|
73
|
+
* `presentedTokens` reads every `master_lock*` cookie a request carries, so a browser
|
|
74
|
+
* holding several presents them all and each app matches only its own live tokens —
|
|
75
|
+
* which is the same constant-time comparison that already decided this.
|
|
76
|
+
*/
|
|
77
|
+
export declare function masterLockCookieName(appKey?: string): string;
|
|
78
|
+
/** Non-browser callers (a smoke, a curl) may present the token here instead. */
|
|
79
|
+
export declare const MASTER_LOCK_HEADER = "x-master-lock";
|
|
80
|
+
/** Set on every refusal this feature authors, so a client can tell "you are locked out"
|
|
81
|
+
* apart from every other 401 an app might answer with. */
|
|
82
|
+
export declare const MASTER_LOCK_STATE_HEADER = "x-master-lock-state";
|
|
83
|
+
/** What `GET /__lock/status` answers. Readable by anyone who got past the app's own auth,
|
|
84
|
+
* and it discloses nothing: a salt, an iteration count and a verdict.
|
|
85
|
+
*
|
|
86
|
+
* @surface output — this DTO is PRODUCED by `MasterLock.status()` and only ever
|
|
87
|
+
* read by consumers (`res.json() as MasterLockStatus` in `MasterLockProvider`);
|
|
88
|
+
* nothing in the fleet constructs one. So adding a required field to it is
|
|
89
|
+
* additive, and without this annotation it is the residual false MAJOR that
|
|
90
|
+
* Phase −1's M-B measured and had no cheap fix for (F4). `release/surface.ts`
|
|
91
|
+
* records the annotation; `release/classify.ts` honours it. */
|
|
92
|
+
export interface MasterLockStatus {
|
|
93
|
+
/** Is a master password set at all? False only on an app that was never seeded,
|
|
94
|
+
* or — in per-person mode — for a person who has not chosen one yet. */
|
|
95
|
+
configured: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* May the caller set a first master password right now (`POST /__lock/enroll`)?
|
|
98
|
+
*
|
|
99
|
+
* True only in per-person mode and only while `configured` is false. It is what
|
|
100
|
+
* lets the lock page show "choose your master password" instead of a box that
|
|
101
|
+
* nothing can open — the state a NEW person is in the first time they sign in.
|
|
102
|
+
*
|
|
103
|
+
* 🔴 Never true once a record exists, so it is not a road to overwriting one.
|
|
104
|
+
*/
|
|
105
|
+
enrollable: boolean;
|
|
106
|
+
locked: boolean;
|
|
107
|
+
/** The params to derive under. `null` when unconfigured. */
|
|
108
|
+
kdf: MasterLockKdfParams | null;
|
|
109
|
+
/** The idle timeout in force, so the page can count down with the server. */
|
|
110
|
+
idleMs: number;
|
|
111
|
+
/** Milliseconds of idleness left before this session lapses; `0` when locked. */
|
|
112
|
+
remainingMs: number;
|
|
113
|
+
/** Milliseconds until another guess will be evaluated. `0` normally. */
|
|
114
|
+
retryAfterMs: number;
|
|
115
|
+
}
|
|
116
|
+
export interface MasterLockUnlockRequest {
|
|
117
|
+
verifier: string;
|
|
118
|
+
}
|
|
119
|
+
/** Choosing a first master password: fresh params, and the verifier under them. */
|
|
120
|
+
export interface MasterLockEnrollRequest {
|
|
121
|
+
kdf: MasterLockKdfParams;
|
|
122
|
+
verifier: string;
|
|
123
|
+
}
|
|
124
|
+
export interface MasterLockChangeRequest {
|
|
125
|
+
/** Derived under the CURRENT params — proof that the person changing it is the person
|
|
126
|
+
* who knows it, re-typed rather than inherited from the open session. */
|
|
127
|
+
currentVerifier: string;
|
|
128
|
+
/** The NEW params (a fresh salt) and the verifier derived under them. */
|
|
129
|
+
kdf: MasterLockKdfParams;
|
|
130
|
+
verifier: string;
|
|
131
|
+
}
|
|
132
|
+
export interface MasterLockSettingsRequest {
|
|
133
|
+
idleMs: number;
|
|
134
|
+
}
|
|
135
|
+
/** Why an unlock was refused. `wrong` and `throttled` are deliberately answered with the
|
|
136
|
+
* SAME status and shape on the wire; the distinction exists so the page can show a
|
|
137
|
+
* countdown, and it is only ever sent once the caller has already proved they are past
|
|
138
|
+
* the app's own auth. */
|
|
139
|
+
export type MasterLockRefusal = "wrong" | "throttled" | "unconfigured" | "locked";
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mount prefix. Everything under it is reachable while LOCKED (it is how you unlock);
|
|
3
|
+
* everything else on the origin is not.
|
|
4
|
+
*
|
|
5
|
+
* `__` because a path a user could plausibly want must never collide with it: collections
|
|
6
|
+
* routes on the hash, and the binary-server inspector owns `/api`, `/raw` and `/preview`.
|
|
7
|
+
*/
|
|
8
|
+
export const MASTER_LOCK_PREFIX = "/__lock";
|
|
9
|
+
export const MASTER_LOCK_PATHS = {
|
|
10
|
+
/** The lock page itself. Also what a locked HTML request is answered with, in place. */
|
|
11
|
+
page: `${MASTER_LOCK_PREFIX}/`,
|
|
12
|
+
style: `${MASTER_LOCK_PREFIX}/lock.css`,
|
|
13
|
+
script: `${MASTER_LOCK_PREFIX}/lock.js`,
|
|
14
|
+
/** Readable while locked — it carries the KDF params the browser needs to derive. */
|
|
15
|
+
status: `${MASTER_LOCK_PREFIX}/status`,
|
|
16
|
+
unlock: `${MASTER_LOCK_PREFIX}/unlock`,
|
|
17
|
+
/** Slides the idle window. Sent on real INTERACTION only — see the engine. */
|
|
18
|
+
ping: `${MASTER_LOCK_PREFIX}/ping`,
|
|
19
|
+
lock: `${MASTER_LOCK_PREFIX}/lock`,
|
|
20
|
+
change: `${MASTER_LOCK_PREFIX}/change`,
|
|
21
|
+
settings: `${MASTER_LOCK_PREFIX}/settings`,
|
|
22
|
+
/**
|
|
23
|
+
* Set the FIRST master password for a principal that has none.
|
|
24
|
+
*
|
|
25
|
+
* Reachable while locked, like {@link MASTER_LOCK_PATHS.unlock}, and for the same
|
|
26
|
+
* reason: it is the only way in. It is refused outright unless the lock is
|
|
27
|
+
* `enrollable` AND has no record — so it can never overwrite a password, only
|
|
28
|
+
* create the one that was never set. Rotation is `change`, which demands the
|
|
29
|
+
* current one.
|
|
30
|
+
*/
|
|
31
|
+
enroll: `${MASTER_LOCK_PREFIX}/enroll`,
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The base name of the cookie the unlock token rides in. Callers want
|
|
35
|
+
* {@link masterLockCookieName}, not this.
|
|
36
|
+
*
|
|
37
|
+
* 🔴 **A cookie is scoped by HOST, never by ORIGIN**, and this constant used to carry a
|
|
38
|
+
* comment claiming the opposite — *"each app is its own origin, so unlocking one never
|
|
39
|
+
* unlocks another … falls out of the browser's own rules"*. It does not. The browser
|
|
40
|
+
* ignores the PORT when deciding which cookies to send, so two locked apps on `localhost`
|
|
41
|
+
* share one jar and each unlock overwrites the other's token.
|
|
42
|
+
*
|
|
43
|
+
* Found 2026-09-11 by walking the feature in a browser, which is the only way it shows:
|
|
44
|
+
* production is fine (collections and the inspector are different hostnames), but on the
|
|
45
|
+
* Mac — where every app is `localhost:<port>` — opening the binary-server inspector left
|
|
46
|
+
* `collections` holding a token its own server had never minted. Nothing is DISCLOSED by
|
|
47
|
+
* that, because an app-wide lock reads the ambient `unlocked` state, so the site stayed
|
|
48
|
+
* exactly as visible as it already was. What broke is every route that requires
|
|
49
|
+
* `presents(req)`, and it broke SILENTLY:
|
|
50
|
+
*
|
|
51
|
+
* · `POST /__lock/lock` — "Lock this site" did nothing but reload an open page
|
|
52
|
+
* · `POST /__lock/settings` — the idle timeout could not be changed
|
|
53
|
+
* · `POST /__lock/change` — the master password could not be rotated
|
|
54
|
+
* · `POST /__lock/ping` — activity stopped sliding the idle clock
|
|
55
|
+
*
|
|
56
|
+
* i.e. three of the owner's clauses, on the machine his whole reason for asking is about.
|
|
57
|
+
* The name is now per app, so the collision cannot happen on any host.
|
|
58
|
+
*/
|
|
59
|
+
export const MASTER_LOCK_COOKIE = "master_lock";
|
|
60
|
+
/**
|
|
61
|
+
* The cookie name for ONE app — `master_lock` for an app that names no key, and
|
|
62
|
+
* `master_lock_<key>` for one that does.
|
|
63
|
+
*
|
|
64
|
+
* The bare name is kept as the no-key answer so an app that has not been given a key
|
|
65
|
+
* behaves exactly as it did, and so a single locked app on a host needs no migration.
|
|
66
|
+
* `presentedTokens` reads every `master_lock*` cookie a request carries, so a browser
|
|
67
|
+
* holding several presents them all and each app matches only its own live tokens —
|
|
68
|
+
* which is the same constant-time comparison that already decided this.
|
|
69
|
+
*/
|
|
70
|
+
export function masterLockCookieName(appKey) {
|
|
71
|
+
const slug = (appKey ?? "")
|
|
72
|
+
.toLowerCase()
|
|
73
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
74
|
+
.replace(/^-+|-+$/g, "");
|
|
75
|
+
return slug ? `${MASTER_LOCK_COOKIE}_${slug}` : MASTER_LOCK_COOKIE;
|
|
76
|
+
}
|
|
77
|
+
/** Non-browser callers (a smoke, a curl) may present the token here instead. */
|
|
78
|
+
export const MASTER_LOCK_HEADER = "x-master-lock";
|
|
79
|
+
/** Set on every refusal this feature authors, so a client can tell "you are locked out"
|
|
80
|
+
* apart from every other 401 an app might answer with. */
|
|
81
|
+
export const MASTER_LOCK_STATE_HEADER = "x-master-lock-state";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface FloodFillBGOptions {
|
|
2
|
+
/** Pixel width of the source buffer. */
|
|
3
|
+
width: number;
|
|
4
|
+
/** Pixel height of the source buffer. */
|
|
5
|
+
height: number;
|
|
6
|
+
/** Source channel count: 3 (RGB) or 4 (RGBA). Output is always RGBA. */
|
|
7
|
+
channels: 3 | 4;
|
|
8
|
+
/**
|
|
9
|
+
* Max color distance (euclidean over RGB, 0–441) from a seed for a pixel to be
|
|
10
|
+
* considered "background". 0 removes only the exact seed color; higher values
|
|
11
|
+
* tolerate gradients/compression noise. Default 32.
|
|
12
|
+
*/
|
|
13
|
+
tolerance?: number;
|
|
14
|
+
/**
|
|
15
|
+
* Seed pixels to fill from, as `[x, y]`. Defaults to the four corners — the usual
|
|
16
|
+
* "remove the backdrop around a subject" case. Out-of-range seeds are ignored.
|
|
17
|
+
*/
|
|
18
|
+
seeds?: ReadonlyArray<readonly [number, number]>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Flood-fill the background to transparent and return a fresh RGBA buffer
|
|
22
|
+
* (`width * height * 4`). The input is never mutated. A pixel is cleared when it is
|
|
23
|
+
* reachable from a seed through 4-connected neighbors that are all within
|
|
24
|
+
* `tolerance` of THAT seed's color — so an interior region matching the background
|
|
25
|
+
* color but walled off by the subject is preserved.
|
|
26
|
+
*/
|
|
27
|
+
export declare function floodFillBGS(pixels: Uint8Array, opts: FloodFillBGOptions): Uint8Array;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Pure background removal — a breadth-first flood fill from the image edges that
|
|
2
|
+
// turns the uniform, edge-connected background transparent. Lives in media-core so
|
|
3
|
+
// it is identical in the Bun server (run on sharp's raw RGBA buffer), the browser,
|
|
4
|
+
// and tests; it touches no DOM, no Bun, no npm dep — just a typed pixel array.
|
|
5
|
+
const DEFAULT_TOLERANCE = 32;
|
|
6
|
+
/** Default seeds: the four corners of the image. */
|
|
7
|
+
function cornerSeeds(width, height) {
|
|
8
|
+
const x1 = width - 1;
|
|
9
|
+
const y1 = height - 1;
|
|
10
|
+
return [
|
|
11
|
+
[0, 0],
|
|
12
|
+
[x1, 0],
|
|
13
|
+
[0, y1],
|
|
14
|
+
[x1, y1],
|
|
15
|
+
];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Flood-fill the background to transparent and return a fresh RGBA buffer
|
|
19
|
+
* (`width * height * 4`). The input is never mutated. A pixel is cleared when it is
|
|
20
|
+
* reachable from a seed through 4-connected neighbors that are all within
|
|
21
|
+
* `tolerance` of THAT seed's color — so an interior region matching the background
|
|
22
|
+
* color but walled off by the subject is preserved.
|
|
23
|
+
*/
|
|
24
|
+
export function floodFillBGS(pixels, opts) {
|
|
25
|
+
const { width, height, channels } = opts;
|
|
26
|
+
const tolerance = opts.tolerance ?? DEFAULT_TOLERANCE;
|
|
27
|
+
const tolSq = tolerance * tolerance;
|
|
28
|
+
const pxCount = width * height;
|
|
29
|
+
if (width <= 0 || height <= 0)
|
|
30
|
+
return new Uint8Array(0);
|
|
31
|
+
if (pixels.length < pxCount * channels) {
|
|
32
|
+
throw new Error(`floodFillBGS: buffer too small (${pixels.length} bytes for ${width}×${height}×${channels})`);
|
|
33
|
+
}
|
|
34
|
+
// Always work in RGBA so the result carries an alpha channel.
|
|
35
|
+
const out = new Uint8Array(pxCount * 4);
|
|
36
|
+
for (let i = 0; i < pxCount; i++) {
|
|
37
|
+
const s = i * channels;
|
|
38
|
+
out[i * 4] = pixels[s];
|
|
39
|
+
out[i * 4 + 1] = pixels[s + 1];
|
|
40
|
+
out[i * 4 + 2] = pixels[s + 2];
|
|
41
|
+
out[i * 4 + 3] = channels === 4 ? pixels[s + 3] : 255;
|
|
42
|
+
}
|
|
43
|
+
const cleared = new Uint8Array(pxCount); // visited+removed marker, 0/1
|
|
44
|
+
const seeds = opts.seeds ?? cornerSeeds(width, height);
|
|
45
|
+
// A flat queue of pixel indices (head pointer → O(1) dequeue). Shared across
|
|
46
|
+
// seeds: `cleared` prevents re-visiting, so one pass over all seeds is correct.
|
|
47
|
+
const queue = new Int32Array(pxCount);
|
|
48
|
+
for (const [sx, sy] of seeds) {
|
|
49
|
+
if (sx < 0 || sy < 0 || sx >= width || sy >= height)
|
|
50
|
+
continue;
|
|
51
|
+
const seedIdx = sy * width + sx;
|
|
52
|
+
if (cleared[seedIdx])
|
|
53
|
+
continue;
|
|
54
|
+
const sr = out[seedIdx * 4];
|
|
55
|
+
const sg = out[seedIdx * 4 + 1];
|
|
56
|
+
const sb = out[seedIdx * 4 + 2];
|
|
57
|
+
let head = 0;
|
|
58
|
+
let tail = 0;
|
|
59
|
+
cleared[seedIdx] = 1;
|
|
60
|
+
queue[tail++] = seedIdx;
|
|
61
|
+
while (head < tail) {
|
|
62
|
+
const idx = queue[head++];
|
|
63
|
+
out[idx * 4 + 3] = 0; // transparent
|
|
64
|
+
const x = idx % width;
|
|
65
|
+
const y = (idx - x) / width;
|
|
66
|
+
// 4-connected neighbors, each enqueued when within tolerance of THIS seed.
|
|
67
|
+
for (let n = 0, k = 0; k < 4; k++) {
|
|
68
|
+
if (k === 0) {
|
|
69
|
+
if (x === 0)
|
|
70
|
+
continue;
|
|
71
|
+
n = idx - 1;
|
|
72
|
+
}
|
|
73
|
+
else if (k === 1) {
|
|
74
|
+
if (x === width - 1)
|
|
75
|
+
continue;
|
|
76
|
+
n = idx + 1;
|
|
77
|
+
}
|
|
78
|
+
else if (k === 2) {
|
|
79
|
+
if (y === 0)
|
|
80
|
+
continue;
|
|
81
|
+
n = idx - width;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
if (y === height - 1)
|
|
85
|
+
continue;
|
|
86
|
+
n = idx + width;
|
|
87
|
+
}
|
|
88
|
+
if (cleared[n])
|
|
89
|
+
continue;
|
|
90
|
+
const dr = out[n * 4] - sr;
|
|
91
|
+
const dg = out[n * 4 + 1] - sg;
|
|
92
|
+
const db = out[n * 4 + 2] - sb;
|
|
93
|
+
if (dr * dr + dg * dg + db * db <= tolSq) {
|
|
94
|
+
cleared[n] = 1;
|
|
95
|
+
queue[tail++] = n;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { VideoFormat, VideoProgressEvent, VideoQuality, VideoTrimSpec } from './types';
|
|
2
|
+
/** Codec + rate-control plan for an output container at a quality preset. */
|
|
3
|
+
export interface VideoCodecPlan {
|
|
4
|
+
videoCodec: string;
|
|
5
|
+
audioCodec: string;
|
|
6
|
+
/** Constant Rate Factor — lower is higher quality (x264 ~18 visually lossless). */
|
|
7
|
+
crf: number;
|
|
8
|
+
preset: string;
|
|
9
|
+
/** Container-specific extra output flags (e.g. faststart for mp4). */
|
|
10
|
+
extraOutputArgs: string[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the codec plan for a trim. mp4 → H.264/AAC (universal playback) with
|
|
14
|
+
* `+faststart` so the moov atom is up front (web streaming); webm → VP9/Opus.
|
|
15
|
+
*/
|
|
16
|
+
export declare function videoCodecPlan(format?: VideoFormat, quality?: VideoQuality): VideoCodecPlan;
|
|
17
|
+
/**
|
|
18
|
+
* Build the ffmpeg argv (without the `ffmpeg` binary) for a re-encoding trim.
|
|
19
|
+
* Uses accurate input seeking (`-ss` before `-i`, accurate when transcoding) plus
|
|
20
|
+
* `-t duration` so the clip is exactly `end - start` long regardless of keyframes.
|
|
21
|
+
*/
|
|
22
|
+
export declare function buildTrimArgs(spec: VideoTrimSpec): string[];
|
|
23
|
+
/** ffprobe argv for `probeMedia` — quiet JSON with streams + format. */
|
|
24
|
+
export declare function buildProbeArgs(inputPath: string): string[];
|
|
25
|
+
/** ffmpeg argv to grab a single frame at `atSeconds` and write it to `output`. */
|
|
26
|
+
export declare function buildThumbnailArgs(inputPath: string, atSeconds: number, output: string): string[];
|
|
27
|
+
/** Parse an ffmpeg `HH:MM:SS.ss` (or bare seconds) timestamp into seconds. */
|
|
28
|
+
export declare function ffmpegTimeToSeconds(time: string): number;
|
|
29
|
+
/**
|
|
30
|
+
* Parse one line/chunk of ffmpeg stderr into a progress event. ffmpeg emits
|
|
31
|
+
* `frame=… time=00:00:03.00 …` (carriage-return-updated). Returns an `encode`
|
|
32
|
+
* event with a clamped percent when `totalSeconds` is known, else just the phase;
|
|
33
|
+
* `null` when the line carries no timestamp.
|
|
34
|
+
*/
|
|
35
|
+
export declare function parseFfmpegProgress(line: string, totalSeconds?: number): VideoProgressEvent | null;
|
|
36
|
+
export interface ProbeResult {
|
|
37
|
+
width: number;
|
|
38
|
+
height: number;
|
|
39
|
+
durationSeconds: number;
|
|
40
|
+
format: string;
|
|
41
|
+
}
|
|
42
|
+
/** Shape of the bits of ffprobe JSON we read. */
|
|
43
|
+
interface FfprobeJson {
|
|
44
|
+
streams?: Array<{
|
|
45
|
+
codec_type?: string;
|
|
46
|
+
width?: number;
|
|
47
|
+
height?: number;
|
|
48
|
+
duration?: string;
|
|
49
|
+
}>;
|
|
50
|
+
format?: {
|
|
51
|
+
duration?: string;
|
|
52
|
+
format_name?: string;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Extract width/height/duration/format from parsed ffprobe JSON. */
|
|
56
|
+
export declare function parseFfprobe(json: FfprobeJson): ProbeResult;
|
|
57
|
+
export {};
|