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,71 @@
|
|
|
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
|
+
|
|
32
|
+
/** The attribute a component uses to overrule the heuristic below. */
|
|
33
|
+
export const PRESENCE_ATTRIBUTE = "data-master-lock-presence";
|
|
34
|
+
|
|
35
|
+
/** The subset of `HTMLMediaElement` this needs — declared so the predicate is
|
|
36
|
+
* testable without a DOM and cannot drift into reading anything else. */
|
|
37
|
+
export interface PresenceMedia {
|
|
38
|
+
paused: boolean;
|
|
39
|
+
ended: boolean;
|
|
40
|
+
muted: boolean;
|
|
41
|
+
loop: boolean;
|
|
42
|
+
/** Seconds. `0` for a stream that has not started, `NaN` before metadata. */
|
|
43
|
+
duration: number;
|
|
44
|
+
getAttribute(name: string): string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Is this one element evidence that a person is watching or listening right now?
|
|
49
|
+
*
|
|
50
|
+
* Order matters: the explicit attribute wins over everything, because a component that
|
|
51
|
+
* says "ignore me" is describing itself better than any heuristic can.
|
|
52
|
+
*/
|
|
53
|
+
export function isPresenceMedia(element: PresenceMedia): boolean {
|
|
54
|
+
const declared = element.getAttribute(PRESENCE_ATTRIBUTE);
|
|
55
|
+
if (declared === "ignore") return false;
|
|
56
|
+
if (element.paused || element.ended) return false;
|
|
57
|
+
if (declared === "watch") return true;
|
|
58
|
+
// The decorative-loop signature. See the module note.
|
|
59
|
+
if (element.muted && element.loop) return false;
|
|
60
|
+
// A clip with no duration yet is a stream still negotiating, not a person watching.
|
|
61
|
+
// `Infinity` is a live stream, which counts.
|
|
62
|
+
return element.duration > 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Is ANY of them? The whole question the idle watcher asks each tick. */
|
|
66
|
+
export function isSomebodyWatching(elements: Iterable<PresenceMedia>): boolean {
|
|
67
|
+
for (const element of elements) {
|
|
68
|
+
if (isPresenceMedia(element)) return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
/**
|
|
10
|
+
* The mount prefix. Everything under it is reachable while LOCKED (it is how you unlock);
|
|
11
|
+
* everything else on the origin is not.
|
|
12
|
+
*
|
|
13
|
+
* `__` because a path a user could plausibly want must never collide with it: collections
|
|
14
|
+
* routes on the hash, and the binary-server inspector owns `/api`, `/raw` and `/preview`.
|
|
15
|
+
*/
|
|
16
|
+
export const MASTER_LOCK_PREFIX = "/__lock";
|
|
17
|
+
|
|
18
|
+
export const MASTER_LOCK_PATHS = {
|
|
19
|
+
/** The lock page itself. Also what a locked HTML request is answered with, in place. */
|
|
20
|
+
page: `${MASTER_LOCK_PREFIX}/`,
|
|
21
|
+
style: `${MASTER_LOCK_PREFIX}/lock.css`,
|
|
22
|
+
script: `${MASTER_LOCK_PREFIX}/lock.js`,
|
|
23
|
+
/** Readable while locked — it carries the KDF params the browser needs to derive. */
|
|
24
|
+
status: `${MASTER_LOCK_PREFIX}/status`,
|
|
25
|
+
unlock: `${MASTER_LOCK_PREFIX}/unlock`,
|
|
26
|
+
/** Slides the idle window. Sent on real INTERACTION only — see the engine. */
|
|
27
|
+
ping: `${MASTER_LOCK_PREFIX}/ping`,
|
|
28
|
+
lock: `${MASTER_LOCK_PREFIX}/lock`,
|
|
29
|
+
change: `${MASTER_LOCK_PREFIX}/change`,
|
|
30
|
+
settings: `${MASTER_LOCK_PREFIX}/settings`,
|
|
31
|
+
/**
|
|
32
|
+
* Set the FIRST master password for a principal that has none.
|
|
33
|
+
*
|
|
34
|
+
* Reachable while locked, like {@link MASTER_LOCK_PATHS.unlock}, and for the same
|
|
35
|
+
* reason: it is the only way in. It is refused outright unless the lock is
|
|
36
|
+
* `enrollable` AND has no record — so it can never overwrite a password, only
|
|
37
|
+
* create the one that was never set. Rotation is `change`, which demands the
|
|
38
|
+
* current one.
|
|
39
|
+
*/
|
|
40
|
+
enroll: `${MASTER_LOCK_PREFIX}/enroll`,
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The base name of the cookie the unlock token rides in. Callers want
|
|
45
|
+
* {@link masterLockCookieName}, not this.
|
|
46
|
+
*
|
|
47
|
+
* 🔴 **A cookie is scoped by HOST, never by ORIGIN**, and this constant used to carry a
|
|
48
|
+
* comment claiming the opposite — *"each app is its own origin, so unlocking one never
|
|
49
|
+
* unlocks another … falls out of the browser's own rules"*. It does not. The browser
|
|
50
|
+
* ignores the PORT when deciding which cookies to send, so two locked apps on `localhost`
|
|
51
|
+
* share one jar and each unlock overwrites the other's token.
|
|
52
|
+
*
|
|
53
|
+
* Found 2026-09-11 by walking the feature in a browser, which is the only way it shows:
|
|
54
|
+
* production is fine (collections and the inspector are different hostnames), but on the
|
|
55
|
+
* Mac — where every app is `localhost:<port>` — opening the binary-server inspector left
|
|
56
|
+
* `collections` holding a token its own server had never minted. Nothing is DISCLOSED by
|
|
57
|
+
* that, because an app-wide lock reads the ambient `unlocked` state, so the site stayed
|
|
58
|
+
* exactly as visible as it already was. What broke is every route that requires
|
|
59
|
+
* `presents(req)`, and it broke SILENTLY:
|
|
60
|
+
*
|
|
61
|
+
* · `POST /__lock/lock` — "Lock this site" did nothing but reload an open page
|
|
62
|
+
* · `POST /__lock/settings` — the idle timeout could not be changed
|
|
63
|
+
* · `POST /__lock/change` — the master password could not be rotated
|
|
64
|
+
* · `POST /__lock/ping` — activity stopped sliding the idle clock
|
|
65
|
+
*
|
|
66
|
+
* i.e. three of the owner's clauses, on the machine his whole reason for asking is about.
|
|
67
|
+
* The name is now per app, so the collision cannot happen on any host.
|
|
68
|
+
*/
|
|
69
|
+
export const MASTER_LOCK_COOKIE = "master_lock";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The cookie name for ONE app — `master_lock` for an app that names no key, and
|
|
73
|
+
* `master_lock_<key>` for one that does.
|
|
74
|
+
*
|
|
75
|
+
* The bare name is kept as the no-key answer so an app that has not been given a key
|
|
76
|
+
* behaves exactly as it did, and so a single locked app on a host needs no migration.
|
|
77
|
+
* `presentedTokens` reads every `master_lock*` cookie a request carries, so a browser
|
|
78
|
+
* holding several presents them all and each app matches only its own live tokens —
|
|
79
|
+
* which is the same constant-time comparison that already decided this.
|
|
80
|
+
*/
|
|
81
|
+
export function masterLockCookieName(appKey?: string): string {
|
|
82
|
+
const slug = (appKey ?? "")
|
|
83
|
+
.toLowerCase()
|
|
84
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
85
|
+
.replace(/^-+|-+$/g, "");
|
|
86
|
+
return slug ? `${MASTER_LOCK_COOKIE}_${slug}` : MASTER_LOCK_COOKIE;
|
|
87
|
+
}
|
|
88
|
+
/** Non-browser callers (a smoke, a curl) may present the token here instead. */
|
|
89
|
+
export const MASTER_LOCK_HEADER = "x-master-lock";
|
|
90
|
+
/** Set on every refusal this feature authors, so a client can tell "you are locked out"
|
|
91
|
+
* apart from every other 401 an app might answer with. */
|
|
92
|
+
export const MASTER_LOCK_STATE_HEADER = "x-master-lock-state";
|
|
93
|
+
|
|
94
|
+
/** What `GET /__lock/status` answers. Readable by anyone who got past the app's own auth,
|
|
95
|
+
* and it discloses nothing: a salt, an iteration count and a verdict.
|
|
96
|
+
*
|
|
97
|
+
* @surface output — this DTO is PRODUCED by `MasterLock.status()` and only ever
|
|
98
|
+
* read by consumers (`res.json() as MasterLockStatus` in `MasterLockProvider`);
|
|
99
|
+
* nothing in the fleet constructs one. So adding a required field to it is
|
|
100
|
+
* additive, and without this annotation it is the residual false MAJOR that
|
|
101
|
+
* Phase −1's M-B measured and had no cheap fix for (F4). `release/surface.ts`
|
|
102
|
+
* records the annotation; `release/classify.ts` honours it. */
|
|
103
|
+
export interface MasterLockStatus {
|
|
104
|
+
/** Is a master password set at all? False only on an app that was never seeded,
|
|
105
|
+
* or — in per-person mode — for a person who has not chosen one yet. */
|
|
106
|
+
configured: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* May the caller set a first master password right now (`POST /__lock/enroll`)?
|
|
109
|
+
*
|
|
110
|
+
* True only in per-person mode and only while `configured` is false. It is what
|
|
111
|
+
* lets the lock page show "choose your master password" instead of a box that
|
|
112
|
+
* nothing can open — the state a NEW person is in the first time they sign in.
|
|
113
|
+
*
|
|
114
|
+
* 🔴 Never true once a record exists, so it is not a road to overwriting one.
|
|
115
|
+
*/
|
|
116
|
+
enrollable: boolean;
|
|
117
|
+
locked: boolean;
|
|
118
|
+
/** The params to derive under. `null` when unconfigured. */
|
|
119
|
+
kdf: MasterLockKdfParams | null;
|
|
120
|
+
/** The idle timeout in force, so the page can count down with the server. */
|
|
121
|
+
idleMs: number;
|
|
122
|
+
/** Milliseconds of idleness left before this session lapses; `0` when locked. */
|
|
123
|
+
remainingMs: number;
|
|
124
|
+
/** Milliseconds until another guess will be evaluated. `0` normally. */
|
|
125
|
+
retryAfterMs: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface MasterLockUnlockRequest {
|
|
129
|
+
verifier: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Choosing a first master password: fresh params, and the verifier under them. */
|
|
133
|
+
export interface MasterLockEnrollRequest {
|
|
134
|
+
kdf: MasterLockKdfParams;
|
|
135
|
+
verifier: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface MasterLockChangeRequest {
|
|
139
|
+
/** Derived under the CURRENT params — proof that the person changing it is the person
|
|
140
|
+
* who knows it, re-typed rather than inherited from the open session. */
|
|
141
|
+
currentVerifier: string;
|
|
142
|
+
/** The NEW params (a fresh salt) and the verifier derived under them. */
|
|
143
|
+
kdf: MasterLockKdfParams;
|
|
144
|
+
verifier: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface MasterLockSettingsRequest {
|
|
148
|
+
idleMs: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Why an unlock was refused. `wrong` and `throttled` are deliberately answered with the
|
|
152
|
+
* SAME status and shape on the wire; the distinction exists so the page can show a
|
|
153
|
+
* countdown, and it is only ever sent once the caller has already proved they are past
|
|
154
|
+
* the app's own auth. */
|
|
155
|
+
export type MasterLockRefusal = "wrong" | "throttled" | "unconfigured" | "locked";
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { floodFillBGS } from './bgRemoval';
|
|
3
|
+
|
|
4
|
+
/** Build a `w×h` RGB buffer from a `pick(x,y) → [r,g,b]` function. */
|
|
5
|
+
function rgb(
|
|
6
|
+
w: number,
|
|
7
|
+
h: number,
|
|
8
|
+
pick: (x: number, y: number) => [number, number, number],
|
|
9
|
+
): Uint8Array {
|
|
10
|
+
const buf = new Uint8Array(w * h * 3);
|
|
11
|
+
for (let y = 0; y < h; y++) {
|
|
12
|
+
for (let x = 0; x < w; x++) {
|
|
13
|
+
const [r, g, b] = pick(x, y);
|
|
14
|
+
const i = (y * w + x) * 3;
|
|
15
|
+
buf[i] = r;
|
|
16
|
+
buf[i + 1] = g;
|
|
17
|
+
buf[i + 2] = b;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return buf;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const alphaAt = (out: Uint8Array, w: number, x: number, y: number) => out[(y * w + x) * 4 + 3];
|
|
24
|
+
|
|
25
|
+
describe('floodFillBGS', () => {
|
|
26
|
+
test('clears a uniform red border but preserves an enclosed blue subject', () => {
|
|
27
|
+
const w = 4;
|
|
28
|
+
const h = 4;
|
|
29
|
+
// Red everywhere except a 2×2 blue block in the middle (x,y ∈ {1,2}).
|
|
30
|
+
const pixels = rgb(w, h, (x, y) =>
|
|
31
|
+
x >= 1 && x <= 2 && y >= 1 && y <= 2 ? [0, 0, 255] : [255, 0, 0],
|
|
32
|
+
);
|
|
33
|
+
const out = floodFillBGS(pixels, { width: w, height: h, channels: 3 });
|
|
34
|
+
|
|
35
|
+
// Output is RGBA, same pixel count.
|
|
36
|
+
expect(out.length).toBe(w * h * 4);
|
|
37
|
+
// Every red border pixel is now transparent.
|
|
38
|
+
expect(alphaAt(out, w, 0, 0)).toBe(0);
|
|
39
|
+
expect(alphaAt(out, w, 3, 0)).toBe(0);
|
|
40
|
+
expect(alphaAt(out, w, 0, 3)).toBe(0);
|
|
41
|
+
// The blue subject stays fully opaque.
|
|
42
|
+
expect(alphaAt(out, w, 1, 1)).toBe(255);
|
|
43
|
+
expect(alphaAt(out, w, 2, 2)).toBe(255);
|
|
44
|
+
// ...and keeps its color.
|
|
45
|
+
const blue = (1 * w + 1) * 4;
|
|
46
|
+
expect([out[blue], out[blue + 1], out[blue + 2]]).toEqual([0, 0, 255]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('an interior pocket matching the background color but walled off is preserved', () => {
|
|
50
|
+
const w = 5;
|
|
51
|
+
const h = 5;
|
|
52
|
+
// Green ring (the subject) around a single red center pixel; red border outside.
|
|
53
|
+
const pixels = rgb(w, h, (x, y) => {
|
|
54
|
+
const onRing = x >= 1 && x <= 3 && y >= 1 && y <= 3 && !(x === 2 && y === 2);
|
|
55
|
+
if (onRing) return [0, 255, 0];
|
|
56
|
+
return [255, 0, 0]; // border + the enclosed center
|
|
57
|
+
});
|
|
58
|
+
const out = floodFillBGS(pixels, { width: w, height: h, channels: 3 });
|
|
59
|
+
// Outer red border: transparent.
|
|
60
|
+
expect(alphaAt(out, w, 0, 0)).toBe(0);
|
|
61
|
+
// The red center is enclosed by green → NOT reachable from a corner seed → opaque.
|
|
62
|
+
expect(alphaAt(out, w, 2, 2)).toBe(255);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('tolerance lets near-colors count as background', () => {
|
|
66
|
+
const w = 3;
|
|
67
|
+
const h = 1;
|
|
68
|
+
// Near-white, near-white, black — seed only from the left so the black stays.
|
|
69
|
+
const pixels = new Uint8Array([250, 250, 250, 245, 248, 250, 0, 0, 0]);
|
|
70
|
+
const tight = floodFillBGS(pixels, {
|
|
71
|
+
width: w,
|
|
72
|
+
height: h,
|
|
73
|
+
channels: 3,
|
|
74
|
+
tolerance: 2,
|
|
75
|
+
seeds: [[0, 0]],
|
|
76
|
+
});
|
|
77
|
+
// Only the seed pixel (250,250,250) clears; the (245,248,250) is >2 away.
|
|
78
|
+
expect(alphaAt(tight, w, 0, 0)).toBe(0);
|
|
79
|
+
expect(alphaAt(tight, w, 1, 0)).toBe(255);
|
|
80
|
+
|
|
81
|
+
const loose = floodFillBGS(pixels, {
|
|
82
|
+
width: w,
|
|
83
|
+
height: h,
|
|
84
|
+
channels: 3,
|
|
85
|
+
tolerance: 20,
|
|
86
|
+
seeds: [[0, 0]],
|
|
87
|
+
});
|
|
88
|
+
expect(alphaAt(loose, w, 0, 0)).toBe(0);
|
|
89
|
+
expect(alphaAt(loose, w, 1, 0)).toBe(0); // within tolerance now
|
|
90
|
+
expect(alphaAt(loose, w, 2, 0)).toBe(255); // black is still far from the seed
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('respects RGBA input and custom seeds', () => {
|
|
94
|
+
const w = 3;
|
|
95
|
+
const h = 1;
|
|
96
|
+
const pixels = new Uint8Array([
|
|
97
|
+
10,
|
|
98
|
+
10,
|
|
99
|
+
10,
|
|
100
|
+
255, // x0 black
|
|
101
|
+
10,
|
|
102
|
+
10,
|
|
103
|
+
10,
|
|
104
|
+
255, // x1 black
|
|
105
|
+
99,
|
|
106
|
+
99,
|
|
107
|
+
99,
|
|
108
|
+
255, // x2 gray
|
|
109
|
+
]);
|
|
110
|
+
// Seed only from x2 (the gray): the two blacks are untouched.
|
|
111
|
+
const out = floodFillBGS(pixels, { width: w, height: h, channels: 4, seeds: [[2, 0]] });
|
|
112
|
+
expect(alphaAt(out, w, 2, 0)).toBe(0);
|
|
113
|
+
expect(alphaAt(out, w, 0, 0)).toBe(255);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('throws on an undersized buffer and returns empty for a zero-size image', () => {
|
|
117
|
+
expect(() => floodFillBGS(new Uint8Array(3), { width: 4, height: 4, channels: 3 })).toThrow();
|
|
118
|
+
expect(floodFillBGS(new Uint8Array(0), { width: 0, height: 0, channels: 3 }).length).toBe(0);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
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
|
+
|
|
6
|
+
export interface FloodFillBGOptions {
|
|
7
|
+
/** Pixel width of the source buffer. */
|
|
8
|
+
width: number;
|
|
9
|
+
/** Pixel height of the source buffer. */
|
|
10
|
+
height: number;
|
|
11
|
+
/** Source channel count: 3 (RGB) or 4 (RGBA). Output is always RGBA. */
|
|
12
|
+
channels: 3 | 4;
|
|
13
|
+
/**
|
|
14
|
+
* Max color distance (euclidean over RGB, 0–441) from a seed for a pixel to be
|
|
15
|
+
* considered "background". 0 removes only the exact seed color; higher values
|
|
16
|
+
* tolerate gradients/compression noise. Default 32.
|
|
17
|
+
*/
|
|
18
|
+
tolerance?: number;
|
|
19
|
+
/**
|
|
20
|
+
* Seed pixels to fill from, as `[x, y]`. Defaults to the four corners — the usual
|
|
21
|
+
* "remove the backdrop around a subject" case. Out-of-range seeds are ignored.
|
|
22
|
+
*/
|
|
23
|
+
seeds?: ReadonlyArray<readonly [number, number]>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_TOLERANCE = 32;
|
|
27
|
+
|
|
28
|
+
/** Default seeds: the four corners of the image. */
|
|
29
|
+
function cornerSeeds(width: number, height: number): Array<readonly [number, number]> {
|
|
30
|
+
const x1 = width - 1;
|
|
31
|
+
const y1 = height - 1;
|
|
32
|
+
return [
|
|
33
|
+
[0, 0],
|
|
34
|
+
[x1, 0],
|
|
35
|
+
[0, y1],
|
|
36
|
+
[x1, y1],
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Flood-fill the background to transparent and return a fresh RGBA buffer
|
|
42
|
+
* (`width * height * 4`). The input is never mutated. A pixel is cleared when it is
|
|
43
|
+
* reachable from a seed through 4-connected neighbors that are all within
|
|
44
|
+
* `tolerance` of THAT seed's color — so an interior region matching the background
|
|
45
|
+
* color but walled off by the subject is preserved.
|
|
46
|
+
*/
|
|
47
|
+
export function floodFillBGS(pixels: Uint8Array, opts: FloodFillBGOptions): Uint8Array {
|
|
48
|
+
const { width, height, channels } = opts;
|
|
49
|
+
const tolerance = opts.tolerance ?? DEFAULT_TOLERANCE;
|
|
50
|
+
const tolSq = tolerance * tolerance;
|
|
51
|
+
const pxCount = width * height;
|
|
52
|
+
|
|
53
|
+
if (width <= 0 || height <= 0) return new Uint8Array(0);
|
|
54
|
+
if (pixels.length < pxCount * channels) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`floodFillBGS: buffer too small (${pixels.length} bytes for ${width}×${height}×${channels})`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Always work in RGBA so the result carries an alpha channel.
|
|
61
|
+
const out = new Uint8Array(pxCount * 4);
|
|
62
|
+
for (let i = 0; i < pxCount; i++) {
|
|
63
|
+
const s = i * channels;
|
|
64
|
+
out[i * 4] = pixels[s];
|
|
65
|
+
out[i * 4 + 1] = pixels[s + 1];
|
|
66
|
+
out[i * 4 + 2] = pixels[s + 2];
|
|
67
|
+
out[i * 4 + 3] = channels === 4 ? pixels[s + 3] : 255;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const cleared = new Uint8Array(pxCount); // visited+removed marker, 0/1
|
|
71
|
+
const seeds = opts.seeds ?? cornerSeeds(width, height);
|
|
72
|
+
|
|
73
|
+
// A flat queue of pixel indices (head pointer → O(1) dequeue). Shared across
|
|
74
|
+
// seeds: `cleared` prevents re-visiting, so one pass over all seeds is correct.
|
|
75
|
+
const queue = new Int32Array(pxCount);
|
|
76
|
+
|
|
77
|
+
for (const [sx, sy] of seeds) {
|
|
78
|
+
if (sx < 0 || sy < 0 || sx >= width || sy >= height) continue;
|
|
79
|
+
const seedIdx = sy * width + sx;
|
|
80
|
+
if (cleared[seedIdx]) continue;
|
|
81
|
+
const sr = out[seedIdx * 4];
|
|
82
|
+
const sg = out[seedIdx * 4 + 1];
|
|
83
|
+
const sb = out[seedIdx * 4 + 2];
|
|
84
|
+
|
|
85
|
+
let head = 0;
|
|
86
|
+
let tail = 0;
|
|
87
|
+
cleared[seedIdx] = 1;
|
|
88
|
+
queue[tail++] = seedIdx;
|
|
89
|
+
|
|
90
|
+
while (head < tail) {
|
|
91
|
+
const idx = queue[head++];
|
|
92
|
+
out[idx * 4 + 3] = 0; // transparent
|
|
93
|
+
const x = idx % width;
|
|
94
|
+
const y = (idx - x) / width;
|
|
95
|
+
// 4-connected neighbors, each enqueued when within tolerance of THIS seed.
|
|
96
|
+
for (let n = 0, k = 0; k < 4; k++) {
|
|
97
|
+
if (k === 0) {
|
|
98
|
+
if (x === 0) continue;
|
|
99
|
+
n = idx - 1;
|
|
100
|
+
} else if (k === 1) {
|
|
101
|
+
if (x === width - 1) continue;
|
|
102
|
+
n = idx + 1;
|
|
103
|
+
} else if (k === 2) {
|
|
104
|
+
if (y === 0) continue;
|
|
105
|
+
n = idx - width;
|
|
106
|
+
} else {
|
|
107
|
+
if (y === height - 1) continue;
|
|
108
|
+
n = idx + width;
|
|
109
|
+
}
|
|
110
|
+
if (cleared[n]) continue;
|
|
111
|
+
const dr = out[n * 4] - sr;
|
|
112
|
+
const dg = out[n * 4 + 1] - sg;
|
|
113
|
+
const db = out[n * 4 + 2] - sb;
|
|
114
|
+
if (dr * dr + dg * dg + db * db <= tolSq) {
|
|
115
|
+
cleared[n] = 1;
|
|
116
|
+
queue[tail++] = n;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
buildProbeArgs,
|
|
4
|
+
buildThumbnailArgs,
|
|
5
|
+
buildTrimArgs,
|
|
6
|
+
ffmpegTimeToSeconds,
|
|
7
|
+
parseFfmpegProgress,
|
|
8
|
+
parseFfprobe,
|
|
9
|
+
videoCodecPlan,
|
|
10
|
+
} from './ffmpeg';
|
|
11
|
+
import type { VideoTrimSpec } from './types';
|
|
12
|
+
|
|
13
|
+
const trim = (over: Partial<VideoTrimSpec> = {}): VideoTrimSpec => ({
|
|
14
|
+
inputPath: '/in.mp4',
|
|
15
|
+
outputPath: '/out.mp4',
|
|
16
|
+
startSeconds: 2,
|
|
17
|
+
endSeconds: 8,
|
|
18
|
+
...over,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('videoCodecPlan', () => {
|
|
22
|
+
test('mp4 → x264/aac with faststart, quality maps to crf', () => {
|
|
23
|
+
const p = videoCodecPlan('mp4', 'high');
|
|
24
|
+
expect(p.videoCodec).toBe('libx264');
|
|
25
|
+
expect(p.audioCodec).toBe('aac');
|
|
26
|
+
expect(p.crf).toBe(18);
|
|
27
|
+
expect(p.extraOutputArgs).toContain('+faststart');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('webm → vp9/opus', () => {
|
|
31
|
+
const p = videoCodecPlan('webm', 'low');
|
|
32
|
+
expect(p.videoCodec).toBe('libvpx-vp9');
|
|
33
|
+
expect(p.audioCodec).toBe('libopus');
|
|
34
|
+
expect(p.crf).toBe(37);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('defaults to mp4 medium', () => {
|
|
38
|
+
expect(videoCodecPlan()).toMatchObject({ videoCodec: 'libx264', crf: 23 });
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('buildTrimArgs', () => {
|
|
43
|
+
test('uses accurate input seek + duration, in the right order', () => {
|
|
44
|
+
const args = buildTrimArgs(trim());
|
|
45
|
+
// -ss before -i (accurate seek when transcoding).
|
|
46
|
+
expect(args.indexOf('-ss')).toBeLessThan(args.indexOf('-i'));
|
|
47
|
+
// duration = end - start = 6.
|
|
48
|
+
const t = args[args.indexOf('-t') + 1];
|
|
49
|
+
expect(Number(t)).toBe(6);
|
|
50
|
+
expect(args[args.indexOf('-ss') + 1]).toBe('2');
|
|
51
|
+
expect(args[args.indexOf('-i') + 1]).toBe('/in.mp4');
|
|
52
|
+
// output path is last.
|
|
53
|
+
expect(args[args.length - 1]).toBe('/out.mp4');
|
|
54
|
+
expect(args).toContain('-crf');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('honors format/quality', () => {
|
|
58
|
+
const args = buildTrimArgs(trim({ format: 'webm', quality: 'high' }));
|
|
59
|
+
expect(args).toContain('libvpx-vp9');
|
|
60
|
+
expect(args[args.indexOf('-crf') + 1]).toBe('24');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('buildProbeArgs / buildThumbnailArgs', () => {
|
|
65
|
+
test('probe asks for quiet json streams + format', () => {
|
|
66
|
+
const a = buildProbeArgs('/v.mp4');
|
|
67
|
+
expect(a).toEqual([
|
|
68
|
+
'-v',
|
|
69
|
+
'quiet',
|
|
70
|
+
'-print_format',
|
|
71
|
+
'json',
|
|
72
|
+
'-show_streams',
|
|
73
|
+
'-show_format',
|
|
74
|
+
'/v.mp4',
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('thumbnail seeks then grabs one frame', () => {
|
|
79
|
+
const a = buildThumbnailArgs('/v.mp4', 3.5, '/t.jpg');
|
|
80
|
+
expect(a[a.indexOf('-ss') + 1]).toBe('3.5');
|
|
81
|
+
expect(a).toContain('-frames:v');
|
|
82
|
+
expect(a[a.indexOf('-frames:v') + 1]).toBe('1');
|
|
83
|
+
expect(a[a.length - 1]).toBe('/t.jpg');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('thumbnail clamps a negative timestamp to 0', () => {
|
|
87
|
+
const a = buildThumbnailArgs('/v.mp4', -2, '/t.jpg');
|
|
88
|
+
expect(a[a.indexOf('-ss') + 1]).toBe('0');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('ffmpegTimeToSeconds', () => {
|
|
93
|
+
test('parses HH:MM:SS.ss and bare seconds', () => {
|
|
94
|
+
expect(ffmpegTimeToSeconds('00:00:06.00')).toBeCloseTo(6, 5);
|
|
95
|
+
expect(ffmpegTimeToSeconds('01:02:03')).toBe(3723);
|
|
96
|
+
expect(ffmpegTimeToSeconds('12.5')).toBe(12.5);
|
|
97
|
+
expect(Number.isNaN(ffmpegTimeToSeconds('N/A'))).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('parseFfmpegProgress', () => {
|
|
102
|
+
test('extracts a percent from a stderr line when total is known', () => {
|
|
103
|
+
const ev = parseFfmpegProgress(
|
|
104
|
+
'frame= 60 fps= 30 q=28.0 size=… time=00:00:03.00 bitrate=…',
|
|
105
|
+
6,
|
|
106
|
+
);
|
|
107
|
+
expect(ev).toEqual({ phase: 'encode', progressPercent: 50 });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('clamps to 100 and reports phase without total', () => {
|
|
111
|
+
expect(parseFfmpegProgress('time=00:00:09.00', 6)?.progressPercent).toBe(100);
|
|
112
|
+
expect(parseFfmpegProgress('time=00:00:01.00')).toEqual({ phase: 'encode' });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('returns null for a line with no timestamp', () => {
|
|
116
|
+
expect(parseFfmpegProgress('Press [q] to stop')).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('parseFfprobe', () => {
|
|
121
|
+
test('reads dimensions/duration/format from the video stream + format block', () => {
|
|
122
|
+
const r = parseFfprobe({
|
|
123
|
+
streams: [
|
|
124
|
+
{ codec_type: 'audio' },
|
|
125
|
+
{ codec_type: 'video', width: 1920, height: 1080, duration: '12.0' },
|
|
126
|
+
],
|
|
127
|
+
format: { duration: '12.34', format_name: 'mov,mp4,m4a' },
|
|
128
|
+
});
|
|
129
|
+
expect(r).toEqual({ width: 1920, height: 1080, durationSeconds: 12.34, format: 'mov,mp4,m4a' });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('tolerates missing fields', () => {
|
|
133
|
+
expect(parseFfprobe({})).toEqual({ width: 0, height: 0, durationSeconds: 0, format: '' });
|
|
134
|
+
});
|
|
135
|
+
});
|