cursedbelt-core 2.14.1 → 2.16.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.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * SAVED PLAYBACK EDITS — skips, and the framing a video plays with, saved beside it and applied
3
+ * while it plays. Not one byte of the stored file changes.
4
+ *
5
+ * The owner, 2026-11-07: *"allow marking center points on the video player at different times so
6
+ * I can play the video zoomed in … It should not actually alter the stored video. It should be
7
+ * able to be toggled off"*, then *"Can we add a trim capability with this feature, where it
8
+ * remembers jump points to go to and skips the points in between. Then later I can have it do the
9
+ * real trim if I want to."*
10
+ *
11
+ * ── One model, read by the editor, the server and the trim drain ──────────────────────────
12
+ * The server refuses what {@link normalizePlayback} refuses and stores what it returns, the
13
+ * editor saves through the same function, and the real trim's copy inherits its original's edits
14
+ * through {@link remapPlayback}. Three readers of one shape is why the shape lives here, beside
15
+ * the cut model, and not in any one of them.
16
+ *
17
+ * 🔴 **Skips ARE trim marks.** A skip is a {@link VideoCut}: the same `[start, end)` span the trim
18
+ * editor marks and binary-server removes, normalized by the same {@link normalizeCuts}. There is
19
+ * no second cut model, so "Make trimmed file…" sends exactly what is already playing.
20
+ *
21
+ * ── Framing: two lanes on one list of keys ─────────────────────────────────────────────────
22
+ * A key may set a center, a zoom, or both. Each LANE (center, zoom) is read only from the keys
23
+ * that set it, so a zoom key never moves the center and a center key never changes the zoom.
24
+ * `toNext` says how a key reaches the next key of the same lane: `hold` keeps its value until
25
+ * then, `glide` moves over the span — center linearly, zoom geometrically (a 1×→4× glide passes
26
+ * 2× halfway, which is what the eye reads as even). Before a lane's first key its first value
27
+ * holds; after its last key its last value holds.
28
+ */
29
+ import { type VideoCut } from './videoCuts.js';
30
+ export type FramingMode = 'hold' | 'glide';
31
+ /** A point of the SOURCE frame, 0–1 from its top-left (as `videoRegion`'s `Region`). */
32
+ export interface FramingPoint {
33
+ x: number;
34
+ y: number;
35
+ }
36
+ export interface FramingKey {
37
+ /** Seconds into the source. */
38
+ t: number;
39
+ center?: FramingPoint;
40
+ /** Magnification, 1 = the whole frame. */
41
+ zoom?: number;
42
+ toNext: FramingMode;
43
+ }
44
+ export interface FramingTrack {
45
+ /** Whole-video zoom, used when no key sets the zoom lane. 1 = none. */
46
+ baseZoom: number;
47
+ keys: FramingKey[];
48
+ }
49
+ export interface PlaybackEdits {
50
+ framing?: FramingTrack;
51
+ /** Spans playback jumps over — the trim editor's marks. */
52
+ skips?: VideoCut[];
53
+ }
54
+ /** The most a saved zoom may magnify — cursedbelt's `MAX_ZOOM` (its spec holds the two equal). */
55
+ export declare const PLAYBACK_MAX_ZOOM = 20;
56
+ export declare const MAX_FRAMING_KEYS = 500;
57
+ export declare const MAX_SKIPS = 200;
58
+ /** Nothing saved — the row is deleted and the item carries no `playback`. */
59
+ export declare const isEmptyPlayback: (p: PlaybackEdits | null | undefined) => boolean;
60
+ /**
61
+ * The ONE gate every writer passes: the server before it stores, the editor before it saves.
62
+ * Out-of-range values are CLAMPED (a center dragged a pixel past the edge is still the edge),
63
+ * keys past the end are dropped, and only what cannot be meant is refused: not an object, too
64
+ * many keys or skips, or skips that remove the whole video.
65
+ *
66
+ * `duration` null means the length is not known: framing still saves, skips are refused —
67
+ * a span cannot be placed in a video of unknown length.
68
+ */
69
+ export declare function normalizePlayback(raw: unknown, duration: number | null): {
70
+ playback: PlaybackEdits;
71
+ } | {
72
+ error: string;
73
+ };
74
+ /**
75
+ * Where playback must be instead of `t`: the end of the skip `t` is inside, or `null` when `t`
76
+ * plays. Half-open, as every cut: `start` jumps, `end` plays. `skips` must be normalized
77
+ * (sorted, merged), which is what {@link normalizePlayback} stores.
78
+ */
79
+ export declare function skipTarget(skips: readonly VideoCut[] | undefined, t: number): number | null;
80
+ /** How long the video plays with its skips — the length a tile shows beside its scissors. */
81
+ export declare function playedSeconds(playback: PlaybackEdits | null | undefined, duration: number): number;
82
+ /** The center lane's value at `t`, or `null` when no key sets a center. */
83
+ export declare function centerAt(track: FramingTrack | null | undefined, t: number): FramingPoint | null;
84
+ /** The zoom lane's value at `t` — `baseZoom` when no key sets a zoom. */
85
+ export declare function zoomAt(track: FramingTrack | null | undefined, t: number): number;
86
+ /**
87
+ * The original's edits, moved onto the trimmed copy. `removed` is what the file was CUT BY — the
88
+ * edit row's stored cuts, never the video's current skips, which may have changed since.
89
+ *
90
+ * · A time after a removed span moves back by the removed time before it.
91
+ * · A skip loses the parts the cut removed; the rest moves back. A skip the cut removed is gone.
92
+ * · A framing key inside a removed span is dropped, and at each cut boundary a key carrying the
93
+ * values in effect at the span's END is inserted, so the copy resumes framed exactly as the
94
+ * original did after the same cut (no jump the original did not have).
95
+ */
96
+ export declare function remapPlayback(playback: PlaybackEdits | null | undefined, removed: readonly VideoCut[], duration: number): PlaybackEdits;
@@ -0,0 +1,236 @@
1
+ /**
2
+ * SAVED PLAYBACK EDITS — skips, and the framing a video plays with, saved beside it and applied
3
+ * while it plays. Not one byte of the stored file changes.
4
+ *
5
+ * The owner, 2026-11-07: *"allow marking center points on the video player at different times so
6
+ * I can play the video zoomed in … It should not actually alter the stored video. It should be
7
+ * able to be toggled off"*, then *"Can we add a trim capability with this feature, where it
8
+ * remembers jump points to go to and skips the points in between. Then later I can have it do the
9
+ * real trim if I want to."*
10
+ *
11
+ * ── One model, read by the editor, the server and the trim drain ──────────────────────────
12
+ * The server refuses what {@link normalizePlayback} refuses and stores what it returns, the
13
+ * editor saves through the same function, and the real trim's copy inherits its original's edits
14
+ * through {@link remapPlayback}. Three readers of one shape is why the shape lives here, beside
15
+ * the cut model, and not in any one of them.
16
+ *
17
+ * 🔴 **Skips ARE trim marks.** A skip is a {@link VideoCut}: the same `[start, end)` span the trim
18
+ * editor marks and binary-server removes, normalized by the same {@link normalizeCuts}. There is
19
+ * no second cut model, so "Make trimmed file…" sends exactly what is already playing.
20
+ *
21
+ * ── Framing: two lanes on one list of keys ─────────────────────────────────────────────────
22
+ * A key may set a center, a zoom, or both. Each LANE (center, zoom) is read only from the keys
23
+ * that set it, so a zoom key never moves the center and a center key never changes the zoom.
24
+ * `toNext` says how a key reaches the next key of the same lane: `hold` keeps its value until
25
+ * then, `glide` moves over the span — center linearly, zoom geometrically (a 1×→4× glide passes
26
+ * 2× halfway, which is what the eye reads as even). Before a lane's first key its first value
27
+ * holds; after its last key its last value holds.
28
+ */
29
+ import { MIN_CUT_SECONDS, normalizeCuts, keptSegments, cutSeconds } from './videoCuts.js';
30
+ /** The most a saved zoom may magnify — cursedbelt's `MAX_ZOOM` (its spec holds the two equal). */
31
+ export const PLAYBACK_MAX_ZOOM = 20;
32
+ export const MAX_FRAMING_KEYS = 500;
33
+ export const MAX_SKIPS = 200;
34
+ /** Two keys closer than this are one moment — the later write wins. */
35
+ const SAME_MOMENT = 1e-3;
36
+ const finite = (v) => typeof v === 'number' && Number.isFinite(v);
37
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
38
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
39
+ /** Nothing saved — the row is deleted and the item carries no `playback`. */
40
+ export const isEmptyPlayback = (p) => !p || ((!p.framing || (p.framing.baseZoom === 1 && p.framing.keys.length === 0)) && (!p.skips || p.skips.length === 0));
41
+ function cleanKey(raw, duration) {
42
+ if (!isObject(raw) || !finite(raw.t) || raw.t < 0)
43
+ return null;
44
+ if (duration !== null && raw.t > duration)
45
+ return null;
46
+ const key = { t: raw.t, toNext: raw.toNext === 'glide' ? 'glide' : 'hold' };
47
+ if (isObject(raw.center) && finite(raw.center.x) && finite(raw.center.y))
48
+ key.center = { x: clamp(raw.center.x, 0, 1), y: clamp(raw.center.y, 0, 1) };
49
+ if (finite(raw.zoom))
50
+ key.zoom = clamp(raw.zoom, 1, PLAYBACK_MAX_ZOOM);
51
+ return key.center || key.zoom !== undefined ? key : null;
52
+ }
53
+ /** Sorted by time; keys at one moment merge, the later in the list winning each lane. */
54
+ function tidyKeys(keys) {
55
+ const sorted = keys.map((k, i) => ({ k, i })).sort((a, b) => a.k.t - b.k.t || a.i - b.i);
56
+ const out = [];
57
+ for (const { k } of sorted) {
58
+ const last = out[out.length - 1];
59
+ if (last && k.t - last.t < SAME_MOMENT) {
60
+ const merged = { ...last, toNext: k.toNext };
61
+ if (k.center)
62
+ merged.center = k.center;
63
+ if (k.zoom !== undefined)
64
+ merged.zoom = k.zoom;
65
+ out[out.length - 1] = merged;
66
+ }
67
+ else
68
+ out.push({ ...k });
69
+ }
70
+ return out;
71
+ }
72
+ /**
73
+ * The ONE gate every writer passes: the server before it stores, the editor before it saves.
74
+ * Out-of-range values are CLAMPED (a center dragged a pixel past the edge is still the edge),
75
+ * keys past the end are dropped, and only what cannot be meant is refused: not an object, too
76
+ * many keys or skips, or skips that remove the whole video.
77
+ *
78
+ * `duration` null means the length is not known: framing still saves, skips are refused —
79
+ * a span cannot be placed in a video of unknown length.
80
+ */
81
+ export function normalizePlayback(raw, duration) {
82
+ if (!isObject(raw))
83
+ return { error: 'Expected an object of playback edits.' };
84
+ const known = finite(duration) && duration > 0 ? duration : null;
85
+ const playback = {};
86
+ if (raw.framing !== undefined && raw.framing !== null) {
87
+ if (!isObject(raw.framing))
88
+ return { error: 'framing must be an object.' };
89
+ const keysRaw = raw.framing.keys ?? [];
90
+ if (!Array.isArray(keysRaw))
91
+ return { error: 'framing.keys must be a list.' };
92
+ if (keysRaw.length > MAX_FRAMING_KEYS)
93
+ return { error: `At most ${MAX_FRAMING_KEYS} framing keys.` };
94
+ const keys = tidyKeys(keysRaw.map((k) => cleanKey(k, known)).filter((k) => k !== null));
95
+ const baseZoom = finite(raw.framing.baseZoom) ? clamp(raw.framing.baseZoom, 1, PLAYBACK_MAX_ZOOM) : 1;
96
+ if (baseZoom !== 1 || keys.length > 0)
97
+ playback.framing = { baseZoom, keys };
98
+ }
99
+ if (raw.skips !== undefined && raw.skips !== null) {
100
+ if (!Array.isArray(raw.skips))
101
+ return { error: 'skips must be a list.' };
102
+ if (raw.skips.length > MAX_SKIPS)
103
+ return { error: `At most ${MAX_SKIPS} skips.` };
104
+ if (raw.skips.length > 0) {
105
+ if (known === null)
106
+ return { error: "This video's length is not known yet, so a skip cannot be placed in it." };
107
+ const spans = raw.skips.filter(isObject).map((s) => ({ start: Number(s.start), end: Number(s.end) }));
108
+ const skips = normalizeCuts(spans, known);
109
+ if (skips.length > 0 && keptSegments(skips, known).length === 0)
110
+ return { error: 'Those skips cover the whole video. Leave at least one section to play.' };
111
+ if (skips.length > 0)
112
+ playback.skips = skips;
113
+ }
114
+ }
115
+ return { playback };
116
+ }
117
+ /**
118
+ * Where playback must be instead of `t`: the end of the skip `t` is inside, or `null` when `t`
119
+ * plays. Half-open, as every cut: `start` jumps, `end` plays. `skips` must be normalized
120
+ * (sorted, merged), which is what {@link normalizePlayback} stores.
121
+ */
122
+ export function skipTarget(skips, t) {
123
+ if (!skips || !finite(t))
124
+ return null;
125
+ for (const s of skips) {
126
+ if (t < s.start)
127
+ return null;
128
+ if (t < s.end)
129
+ return s.end;
130
+ }
131
+ return null;
132
+ }
133
+ /** How long the video plays with its skips — the length a tile shows beside its scissors. */
134
+ export function playedSeconds(playback, duration) {
135
+ if (!finite(duration) || duration <= 0)
136
+ return 0;
137
+ return Math.max(0, duration - cutSeconds(playback?.skips ?? [], duration));
138
+ }
139
+ // ── The lanes ─────────────────────────────────────────────────────────────────────────────
140
+ /** The center lane's value at `t`, or `null` when no key sets a center. */
141
+ export function centerAt(track, t) {
142
+ const keys = (track?.keys ?? []).filter((k) => !!k.center);
143
+ return laneAt(keys, t, (k) => k.center, (a, b, f) => ({ x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f }));
144
+ }
145
+ /** The zoom lane's value at `t` — `baseZoom` when no key sets a zoom. */
146
+ export function zoomAt(track, t) {
147
+ if (!track)
148
+ return 1;
149
+ const keys = track.keys.filter((k) => k.zoom !== undefined);
150
+ return laneAt(keys, t, (k) => k.zoom, (a, b, f) => Math.exp(Math.log(a) + (Math.log(b) - Math.log(a)) * f)) ?? track.baseZoom;
151
+ }
152
+ function laneAt(keys, t, value, mix) {
153
+ const first = keys[0];
154
+ if (!first)
155
+ return null;
156
+ if (!(t > first.t))
157
+ return value(first);
158
+ for (let i = 0; i < keys.length; i++) {
159
+ const key = keys[i];
160
+ const next = keys[i + 1];
161
+ if (!next)
162
+ return value(key);
163
+ if (t < next.t) {
164
+ if (key.toNext === 'hold' || next.t <= key.t)
165
+ return value(key);
166
+ return mix(value(key), value(next), (t - key.t) / (next.t - key.t));
167
+ }
168
+ }
169
+ return value(keys[keys.length - 1]);
170
+ }
171
+ // ── After a real trim ─────────────────────────────────────────────────────────────────────
172
+ /**
173
+ * The original's edits, moved onto the trimmed copy. `removed` is what the file was CUT BY — the
174
+ * edit row's stored cuts, never the video's current skips, which may have changed since.
175
+ *
176
+ * · A time after a removed span moves back by the removed time before it.
177
+ * · A skip loses the parts the cut removed; the rest moves back. A skip the cut removed is gone.
178
+ * · A framing key inside a removed span is dropped, and at each cut boundary a key carrying the
179
+ * values in effect at the span's END is inserted, so the copy resumes framed exactly as the
180
+ * original did after the same cut (no jump the original did not have).
181
+ */
182
+ export function remapPlayback(playback, removed, duration) {
183
+ if (!playback)
184
+ return {};
185
+ const cuts = normalizeCuts(removed, duration);
186
+ const keptTotal = duration - cutSeconds(cuts, duration);
187
+ const inside = (t) => cuts.some((c) => t >= c.start && t < c.end);
188
+ const mapTime = (t) => {
189
+ let shift = 0;
190
+ for (const c of cuts)
191
+ if (c.end <= t)
192
+ shift += c.end - c.start;
193
+ return t - shift;
194
+ };
195
+ const out = {};
196
+ if (playback.skips?.length) {
197
+ const parts = [];
198
+ for (const skip of playback.skips) {
199
+ let from = skip.start;
200
+ for (const c of cuts) {
201
+ if (c.end <= from || c.start >= skip.end)
202
+ continue;
203
+ if (c.start > from)
204
+ parts.push({ start: mapTime(from), end: mapTime(c.start) });
205
+ from = Math.max(from, c.end);
206
+ }
207
+ if (skip.end - from >= MIN_CUT_SECONDS)
208
+ parts.push({ start: mapTime(from), end: mapTime(skip.end) });
209
+ }
210
+ const skips = normalizeCuts(parts, keptTotal);
211
+ if (skips.length)
212
+ out.skips = skips;
213
+ }
214
+ const framing = playback.framing;
215
+ if (framing && (framing.keys.length || framing.baseZoom !== 1)) {
216
+ const keys = framing.keys.filter((k) => !inside(k.t)).map((k) => ({ ...k, t: mapTime(k.t) }));
217
+ const hasCenter = framing.keys.some((k) => k.center);
218
+ const hasZoom = framing.keys.some((k) => k.zoom !== undefined);
219
+ for (const c of cuts) {
220
+ if (c.end >= duration || !(hasCenter || hasZoom))
221
+ continue;
222
+ const governing = [...framing.keys].reverse().find((k) => k.t <= c.end);
223
+ const key = { t: mapTime(c.end), toNext: governing?.toNext ?? 'hold' };
224
+ const center = centerAt(framing, c.end);
225
+ if (hasCenter && center)
226
+ key.center = center;
227
+ if (hasZoom)
228
+ key.zoom = zoomAt(framing, c.end);
229
+ keys.push(key);
230
+ }
231
+ const tidy = tidyKeys(keys).filter((k) => k.t <= keptTotal);
232
+ if (tidy.length || framing.baseZoom !== 1)
233
+ out.framing = { baseZoom: framing.baseZoom, keys: tidy };
234
+ }
235
+ return out;
236
+ }
@@ -150,3 +150,9 @@ export declare function toggleSegment(plan: CutPlan, segment: VideoCut, duration
150
150
  export declare function planCuts(plan: CutPlan, duration: number): VideoCut[];
151
151
  /** Why this plan cannot be sent, or `null`. The same two refusals, asked of a plan. */
152
152
  export declare function planProblem(plan: CutPlan, duration: number): string | null;
153
+ /**
154
+ * A plan that marks exactly `cuts` — how the editor reopens on a video's SAVED skips (forge task
155
+ * 2026-11-07-23:39). Each cut's bounds become splits and the pieces between them are marked removed,
156
+ * so `planCuts(planFromCuts(c, d), d)` is `normalizeCuts(c, d)`.
157
+ */
158
+ export declare function planFromCuts(cuts: readonly VideoCut[], duration: number): CutPlan;
@@ -282,3 +282,13 @@ export function planCuts(plan, duration) {
282
282
  export function planProblem(plan, duration) {
283
283
  return cutListProblem(planCuts(plan, duration), duration);
284
284
  }
285
+ /**
286
+ * A plan that marks exactly `cuts` — how the editor reopens on a video's SAVED skips (forge task
287
+ * 2026-11-07-23:39). Each cut's bounds become splits and the pieces between them are marked removed,
288
+ * so `planCuts(planFromCuts(c, d), d)` is `normalizeCuts(c, d)`.
289
+ */
290
+ export function planFromCuts(cuts, duration) {
291
+ const removed = normalizeCuts(cuts, duration);
292
+ const splits = removed.flatMap((cut) => [cut.start, cut.end]).filter((t) => t > 0 && t < duration);
293
+ return { splits: [...new Set(splits)], removed };
294
+ }
@@ -18,8 +18,9 @@ import { sha256Hex } from './digest.js';
18
18
  import { ABBREV_TO_METHOD, CTGR_WIRE_VERSION, METHOD_TO_ABBREV, } from './types.js';
19
19
  const DEFAULT_CHUNK_SIZE = 1800;
20
20
  const DEFAULT_GZIP_THRESHOLD = 1024;
21
- const textEncoder = new TextEncoder();
22
- const textDecoder = new TextDecoder();
21
+ // Pure, so a bundler can leave the codec out of a chunk that only lazy-loads it (forge task 4805).
22
+ const textEncoder = /* @__PURE__ */ new TextEncoder();
23
+ const textDecoder = /* @__PURE__ */ new TextDecoder();
23
24
  /**
24
25
  * Encode a mutating request into one or more `/ctgr<XX>` GET-twin chunks. Small
25
26
  * bodies become a single chunk; large bodies gzip (when over the threshold) and split
@@ -3,7 +3,9 @@
3
3
  * native addon. Client and server share this exact implementation so a chunk's
4
4
  * `_crc` integrity check computes the same value on both ends of the tunnel.
5
5
  */
6
- const TABLE = (() => {
6
+ // Marked pure so a bundler drops the table with the codec: a client that lazy-loads the codec
7
+ // (`installCtgrFetch`) must not carry it in its entry chunk (forge task 4805).
8
+ const TABLE = /* @__PURE__ */ (() => {
7
9
  const table = new Uint32Array(256);
8
10
  for (let n = 0; n < 256; n++) {
9
11
  let c = n;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The same-origin `/api/…` path (with its query) a URL names, or null when it is another
3
+ * origin or a path the request layer does not own (Cloudflare's `/cdn-cgi/rum` beacon, an
4
+ * asset, a twin that is already converted).
5
+ */
6
+ export declare const ownApiPath: (rawUrl: string, base: string) => string | null;
7
+ /**
8
+ * Arm the guard, then route every same-origin `/api/` mutation through the GET tunnel while
9
+ * the mode is on. Call once at boot, after `applyCtgrFromLocation()` and before anything
10
+ * fetches. Idempotent; a no-op with no `window.fetch`.
11
+ */
12
+ export declare const installCtgrFetch: () => void;
@@ -0,0 +1,88 @@
1
+ /*
2
+ * GET-only mode for a whole site in ONE boot call: `installCtgrFetch()`.
3
+ *
4
+ * auth and desk route each request through `convertIfOnlyGETRequests` + `sendCtgrRequests`
5
+ * by hand, inside their own API client. An app whose mutations are spread over dozens of
6
+ * `fetch` call sites (music, 2026-11-09: the owner opened it with `?ctgr=1` on a machine whose
7
+ * network refuses every non-GET, and nothing in it went through the tunnel) would need each
8
+ * one rewritten. This wraps `window.fetch` instead: while the device's mode is on, a mutating
9
+ * request to this origin's own `/api/…` leaves as its `/ctgr<xx>` GET twin(s) and answers with
10
+ * the one real response; everything else — a GET, another origin, a path the app does not own
11
+ * — passes through untouched. With the mode off it is a pass-through.
12
+ *
13
+ * 🔴 It arms the guard FIRST and wraps it, never the other way round: wrapped by the guard,
14
+ * a POST would be refused before it could be converted. The server half is `mountCtgrTunnel`
15
+ * (`cursedbelt-server/ctgr-tunnel`), which re-dispatches each twin into the real route.
16
+ */
17
+ import { isCtgrModeOn } from './ctgrConfig.js';
18
+ import { installCtgrGuard } from './ctgrGuard.js';
19
+ /**
20
+ * The codec (gzip, sha-256, crc, base64url) loads only on a device that is in the mode: in an
21
+ * app's entry chunk it cost every visitor ~4.5 kB gzip (music, measured 2026-11-09) for a path
22
+ * almost none of them take. A bundler splits it out at this `import()`.
23
+ */
24
+ const loadTunnel = () => Promise.all([import('./convertRequest.js'), import('./sendRequests.js')]).then(([c, s]) => ({
25
+ convertIfOnlyGETRequests: c.convertIfOnlyGETRequests,
26
+ sendCtgrRequests: s.sendCtgrRequests,
27
+ }));
28
+ const SAFE_METHODS = new Set(['GET', 'HEAD']);
29
+ /**
30
+ * The same-origin `/api/…` path (with its query) a URL names, or null when it is another
31
+ * origin or a path the request layer does not own (Cloudflare's `/cdn-cgi/rum` beacon, an
32
+ * asset, a twin that is already converted).
33
+ */
34
+ export const ownApiPath = (rawUrl, base) => {
35
+ try {
36
+ const u = new URL(rawUrl, base);
37
+ if (u.origin !== new URL(base).origin)
38
+ return null;
39
+ return u.pathname.startsWith('/api/') ? `${u.pathname}${u.search}` : null;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ };
45
+ /** A `Request`'s body as something the converter carries: its form, or its text. */
46
+ const bodyOfRequest = async (request) => {
47
+ if (!request.body)
48
+ return undefined;
49
+ const type = request.headers.get('content-type') ?? '';
50
+ return type.includes('multipart/form-data') ? request.clone().formData() : request.clone().text();
51
+ };
52
+ let installed = false;
53
+ /**
54
+ * Arm the guard, then route every same-origin `/api/` mutation through the GET tunnel while
55
+ * the mode is on. Call once at boot, after `applyCtgrFromLocation()` and before anything
56
+ * fetches. Idempotent; a no-op with no `window.fetch`.
57
+ */
58
+ export const installCtgrFetch = () => {
59
+ if (installed || typeof window === 'undefined' || typeof window.fetch !== 'function')
60
+ return;
61
+ installed = true;
62
+ installCtgrGuard();
63
+ const inner = window.fetch.bind(window);
64
+ const tunnelled = async (input, init) => {
65
+ if (!isCtgrModeOn())
66
+ return inner(input, init);
67
+ const request = input instanceof Request ? input : null;
68
+ const method = (init?.method ?? request?.method ?? 'GET').toUpperCase();
69
+ if (SAFE_METHODS.has(method))
70
+ return inner(input, init);
71
+ const href = request ? request.url : input instanceof URL ? input.href : String(input);
72
+ const path = ownApiPath(href, window.location.href);
73
+ if (path === null)
74
+ return inner(input, init);
75
+ const effective = { ...init, method };
76
+ if (request) {
77
+ effective.headers ??= request.headers;
78
+ effective.credentials ??= request.credentials;
79
+ effective.signal ??= request.signal;
80
+ if (init?.body === undefined)
81
+ effective.body = await bodyOfRequest(request);
82
+ }
83
+ const { convertIfOnlyGETRequests, sendCtgrRequests } = await loadTunnel();
84
+ const { requests } = await convertIfOnlyGETRequests(path, effective);
85
+ return sendCtgrRequests(requests, (twin) => inner(twin.url, twin.init));
86
+ };
87
+ window.fetch = tunnelled;
88
+ };
@@ -1,4 +1,5 @@
1
1
  export { applyCtgrFromLocation, applyCtgrIntent, isCtgrModeOn, readCtgrIntent, setCtgrMode, } from './ctgrConfig.js';
2
2
  export { type CtgrFetchRequest, convertIfOnlyGETRequests } from './convertRequest.js';
3
+ export { installCtgrFetch, ownApiPath } from './ctgrFetch.js';
3
4
  export { installCtgrGuard } from './ctgrGuard.js';
4
5
  export { CTGR_INCOMPLETE_STATUS, type CtgrSend, type SendCtgrOptions, sendCtgrRequests, } from './sendRequests.js';
@@ -9,9 +9,12 @@
9
9
  * The wire codec is `cursedbelt-core/ctgr`; the server that decodes the twins is
10
10
  * `cursedbelt-server` (`mountCtgrTunnel`). A site opts in with two boot calls —
11
11
  * `applyCtgrFromLocation()` then `installCtgrGuard()` — and routes its requests
12
- * through `convertIfOnlyGETRequests`, then `sendCtgrRequests`.
12
+ * through `convertIfOnlyGETRequests`, then `sendCtgrRequests`. A site whose mutations
13
+ * are spread over many `fetch` calls calls `installCtgrFetch()` instead of the guard,
14
+ * and every same-origin `/api/` mutation takes the tunnel with no call site changed.
13
15
  */
14
16
  export { applyCtgrFromLocation, applyCtgrIntent, isCtgrModeOn, readCtgrIntent, setCtgrMode, } from './ctgrConfig.js';
15
17
  export { convertIfOnlyGETRequests } from './convertRequest.js';
18
+ export { installCtgrFetch, ownApiPath } from './ctgrFetch.js';
16
19
  export { installCtgrGuard } from './ctgrGuard.js';
17
20
  export { CTGR_INCOMPLETE_STATUS, sendCtgrRequests, } from './sendRequests.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-core",
3
- "version": "2.14.1",
3
+ "version": "2.16.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "Framework-agnostic core of the cursedbelt split — isomorphic domain logic. cwip below it, cursedbelt-server and cursedbelt (react) above it.",
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "scripts": {
19
19
  "clean": "rm -rf dist",
20
- "typecheck": "tsc -p tsconfig.build.json --noEmit",
20
+ "typecheck": "tsc -p tsconfig.json --noEmit",
21
21
  "test": "NODE_ENV=development bun test --timeout 60000 --parallel=4 src",
22
22
  "build": "bun run clean && tsc -p tsconfig.build.json",
23
23
  "paths": "bun run scripts/paths.ts",
@@ -241,6 +241,12 @@
241
241
  "source": "./src/core/media/uploadLimits.ts",
242
242
  "import": "./dist/core/media/uploadLimits.js"
243
243
  },
244
+ "./media/playback-edits": {
245
+ "types": "./dist/core/media/playbackEdits.d.ts",
246
+ "bun": "./src/core/media/playbackEdits.ts",
247
+ "source": "./src/core/media/playbackEdits.ts",
248
+ "import": "./dist/core/media/playbackEdits.js"
249
+ },
244
250
  "./media/video-cuts": {
245
251
  "types": "./dist/core/media/videoCuts.d.ts",
246
252
  "bun": "./src/core/media/videoCuts.ts",
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Saved playback edits: the gate every writer passes, the two framing lanes, skips, and the
3
+ * remap a real trim's copy inherits.
4
+ */
5
+ import { describe, expect, test } from 'bun:test';
6
+ import {
7
+ centerAt,
8
+ type FramingTrack,
9
+ isEmptyPlayback,
10
+ MAX_FRAMING_KEYS,
11
+ normalizePlayback,
12
+ playedSeconds,
13
+ remapPlayback,
14
+ skipTarget,
15
+ zoomAt,
16
+ } from './playbackEdits';
17
+
18
+ const ok = (raw: unknown, duration: number | null = 100) => {
19
+ const r = normalizePlayback(raw, duration);
20
+ if ('error' in r) throw new Error(r.error);
21
+ return r.playback;
22
+ };
23
+
24
+ describe('normalizePlayback — the one gate', () => {
25
+ test('refuses what cannot be meant, with a sentence', () => {
26
+ expect(normalizePlayback(null, 100)).toEqual({ error: expect.any(String) });
27
+ expect(normalizePlayback([], 100)).toEqual({ error: expect.any(String) });
28
+ expect(normalizePlayback({ skips: 'x' }, 100)).toEqual({ error: expect.any(String) });
29
+ expect(normalizePlayback({ framing: { keys: Array.from({ length: MAX_FRAMING_KEYS + 1 }, (_, t) => ({ t, zoom: 2 })) } }, 1000)).toEqual({
30
+ error: expect.stringContaining('500'),
31
+ });
32
+ expect(normalizePlayback({ skips: [{ start: 0, end: 100 }] }, 100)).toEqual({ error: expect.stringContaining('whole video') });
33
+ expect(normalizePlayback({ skips: [{ start: 1, end: 2 }] }, null)).toEqual({ error: expect.stringContaining('not known') });
34
+ });
35
+
36
+ test('clamps, sorts, merges a moment, and drops keys past the end or setting nothing', () => {
37
+ const p = ok({
38
+ framing: {
39
+ baseZoom: 99,
40
+ keys: [
41
+ { t: 50, zoom: 0.2, toNext: 'glide' },
42
+ { t: 10, center: { x: -1, y: 2 } },
43
+ { t: 10.0001, zoom: 3 },
44
+ { t: 101, zoom: 2 },
45
+ { t: 20 },
46
+ ],
47
+ },
48
+ });
49
+ expect(p.framing).toEqual({
50
+ baseZoom: 20,
51
+ keys: [
52
+ { t: 10, center: { x: 0, y: 1 }, zoom: 3, toNext: 'hold' },
53
+ { t: 50, zoom: 1, toNext: 'glide' },
54
+ ],
55
+ });
56
+ });
57
+
58
+ test('skips go through the cut model: merged, sorted, inside the clip', () => {
59
+ expect(ok({ skips: [{ start: 30, end: 40 }, { start: 5, end: 10 }, { start: 35, end: 50 }, { start: 90, end: 200 }] }).skips).toEqual([
60
+ { start: 5, end: 10 },
61
+ { start: 30, end: 50 },
62
+ { start: 90, end: 100 },
63
+ ]);
64
+ });
65
+
66
+ test('nothing saved is empty — the row is deleted', () => {
67
+ const p = ok({ framing: { baseZoom: 1, keys: [] }, skips: [] });
68
+ expect(p).toEqual({});
69
+ expect(isEmptyPlayback(p)).toBe(true);
70
+ expect(isEmptyPlayback(ok({ skips: [{ start: 1, end: 2 }] }))).toBe(false);
71
+ });
72
+ });
73
+
74
+ describe('skips', () => {
75
+ const skips = [
76
+ { start: 10, end: 20 },
77
+ { start: 50, end: 60 },
78
+ ];
79
+ test('a time inside a skip goes to its end; start jumps, end plays', () => {
80
+ expect(skipTarget(skips, 9.99)).toBeNull();
81
+ expect(skipTarget(skips, 10)).toBe(20);
82
+ expect(skipTarget(skips, 15)).toBe(20);
83
+ expect(skipTarget(skips, 20)).toBeNull();
84
+ expect(skipTarget(skips, 55)).toBe(60);
85
+ expect(skipTarget(undefined, 15)).toBeNull();
86
+ });
87
+ test('the played length is the duration less the skips', () => {
88
+ expect(playedSeconds({ skips }, 100)).toBe(80);
89
+ expect(playedSeconds(null, 100)).toBe(100);
90
+ });
91
+ });
92
+
93
+ describe('the framing lanes', () => {
94
+ const track: FramingTrack = {
95
+ baseZoom: 1.5,
96
+ keys: [
97
+ { t: 10, center: { x: 0.2, y: 0.2 }, toNext: 'glide' },
98
+ { t: 20, zoom: 1, toNext: 'glide' },
99
+ { t: 30, center: { x: 0.6, y: 0.8 }, toNext: 'hold' },
100
+ { t: 40, zoom: 4, toNext: 'hold' },
101
+ { t: 50, center: { x: 0.9, y: 0.1 }, toNext: 'hold' },
102
+ ],
103
+ };
104
+ test('center glides linearly between ITS keys; a zoom key between them moves nothing', () => {
105
+ expect(centerAt(track, 20)).toEqual({ x: 0.4, y: 0.5 });
106
+ expect(centerAt(track, 25)?.x).toBeCloseTo(0.5);
107
+ });
108
+ test('hold keeps a value until the next key of the lane', () => {
109
+ expect(centerAt(track, 49.9)).toEqual({ x: 0.6, y: 0.8 });
110
+ expect(centerAt(track, 50)).toEqual({ x: 0.9, y: 0.1 });
111
+ });
112
+ test('zoom glides in log space: 1× → 4× is 2× halfway', () => {
113
+ expect(zoomAt(track, 30)).toBeCloseTo(2);
114
+ expect(zoomAt(track, 45)).toBe(4);
115
+ });
116
+ test('before the first key the first value holds; after the last, the last', () => {
117
+ expect(centerAt(track, 0)).toEqual({ x: 0.2, y: 0.2 });
118
+ expect(centerAt(track, 999)).toEqual({ x: 0.9, y: 0.1 });
119
+ expect(zoomAt(track, 0)).toBe(1);
120
+ });
121
+ test('no zoom key: the whole-video zoom; no track at all: none', () => {
122
+ expect(zoomAt({ baseZoom: 2.5, keys: [{ t: 3, center: { x: 0.5, y: 0.5 }, toNext: 'hold' }] }, 50)).toBe(2.5);
123
+ expect(zoomAt(null, 5)).toBe(1);
124
+ expect(centerAt({ baseZoom: 2, keys: [] }, 5)).toBeNull();
125
+ });
126
+ });
127
+
128
+ describe('remapPlayback — the trimmed copy inherits the edits', () => {
129
+ test('skips shift back by the removed time before them; a cut skip is gone; a straddling one is clipped', () => {
130
+ const p = { skips: [{ start: 10, end: 15 }, { start: 30, end: 40 }, { start: 70, end: 80 }] };
131
+ // remove [8, 16) (swallows the first skip) and [35, 50) (clips the second)
132
+ expect(remapPlayback(p, [{ start: 8, end: 16 }, { start: 35, end: 50 }], 100).skips).toEqual([
133
+ { start: 22, end: 27 },
134
+ { start: 47, end: 57 },
135
+ ]);
136
+ });
137
+ test('a key inside a removed span is dropped and the cut boundary gets the values in effect at its end', () => {
138
+ const framing: FramingTrack = {
139
+ baseZoom: 1,
140
+ keys: [
141
+ { t: 0, center: { x: 0, y: 0 }, zoom: 1, toNext: 'glide' },
142
+ { t: 20, center: { x: 1, y: 1 }, zoom: 4, toNext: 'hold' },
143
+ { t: 60, zoom: 2, toNext: 'hold' },
144
+ ],
145
+ };
146
+ const out = remapPlayback({ framing }, [{ start: 5, end: 15 }], 100).framing;
147
+ expect(out?.keys).toEqual([
148
+ { t: 0, center: { x: 0, y: 0 }, zoom: 1, toNext: 'glide' },
149
+ { t: 5, center: { x: 0.75, y: 0.75 }, zoom: zoomAt(framing, 15), toNext: 'glide' },
150
+ { t: 10, center: { x: 1, y: 1 }, zoom: 4, toNext: 'hold' },
151
+ { t: 50, zoom: 2, toNext: 'hold' },
152
+ ]);
153
+ // After the cut, the copy is framed as the original was after the same cut.
154
+ for (const t of [16, 30, 70]) {
155
+ expect(centerAt(out, t - 10)).toEqual(centerAt(framing, t) as { x: number; y: number });
156
+ expect(zoomAt(out, t - 10)).toBeCloseTo(zoomAt(framing, t));
157
+ }
158
+ });
159
+ test('nothing saved stays nothing', () => {
160
+ expect(remapPlayback(null, [{ start: 1, end: 2 }], 10)).toEqual({});
161
+ });
162
+ });
@@ -0,0 +1,260 @@
1
+ /**
2
+ * SAVED PLAYBACK EDITS — skips, and the framing a video plays with, saved beside it and applied
3
+ * while it plays. Not one byte of the stored file changes.
4
+ *
5
+ * The owner, 2026-11-07: *"allow marking center points on the video player at different times so
6
+ * I can play the video zoomed in … It should not actually alter the stored video. It should be
7
+ * able to be toggled off"*, then *"Can we add a trim capability with this feature, where it
8
+ * remembers jump points to go to and skips the points in between. Then later I can have it do the
9
+ * real trim if I want to."*
10
+ *
11
+ * ── One model, read by the editor, the server and the trim drain ──────────────────────────
12
+ * The server refuses what {@link normalizePlayback} refuses and stores what it returns, the
13
+ * editor saves through the same function, and the real trim's copy inherits its original's edits
14
+ * through {@link remapPlayback}. Three readers of one shape is why the shape lives here, beside
15
+ * the cut model, and not in any one of them.
16
+ *
17
+ * 🔴 **Skips ARE trim marks.** A skip is a {@link VideoCut}: the same `[start, end)` span the trim
18
+ * editor marks and binary-server removes, normalized by the same {@link normalizeCuts}. There is
19
+ * no second cut model, so "Make trimmed file…" sends exactly what is already playing.
20
+ *
21
+ * ── Framing: two lanes on one list of keys ─────────────────────────────────────────────────
22
+ * A key may set a center, a zoom, or both. Each LANE (center, zoom) is read only from the keys
23
+ * that set it, so a zoom key never moves the center and a center key never changes the zoom.
24
+ * `toNext` says how a key reaches the next key of the same lane: `hold` keeps its value until
25
+ * then, `glide` moves over the span — center linearly, zoom geometrically (a 1×→4× glide passes
26
+ * 2× halfway, which is what the eye reads as even). Before a lane's first key its first value
27
+ * holds; after its last key its last value holds.
28
+ */
29
+ import { MIN_CUT_SECONDS, normalizeCuts, keptSegments, cutSeconds, type VideoCut } from './videoCuts.js';
30
+
31
+ export type FramingMode = 'hold' | 'glide';
32
+
33
+ /** A point of the SOURCE frame, 0–1 from its top-left (as `videoRegion`'s `Region`). */
34
+ export interface FramingPoint {
35
+ x: number;
36
+ y: number;
37
+ }
38
+
39
+ export interface FramingKey {
40
+ /** Seconds into the source. */
41
+ t: number;
42
+ center?: FramingPoint;
43
+ /** Magnification, 1 = the whole frame. */
44
+ zoom?: number;
45
+ toNext: FramingMode;
46
+ }
47
+
48
+ export interface FramingTrack {
49
+ /** Whole-video zoom, used when no key sets the zoom lane. 1 = none. */
50
+ baseZoom: number;
51
+ keys: FramingKey[];
52
+ }
53
+
54
+ export interface PlaybackEdits {
55
+ framing?: FramingTrack;
56
+ /** Spans playback jumps over — the trim editor's marks. */
57
+ skips?: VideoCut[];
58
+ }
59
+
60
+ /** The most a saved zoom may magnify — cursedbelt's `MAX_ZOOM` (its spec holds the two equal). */
61
+ export const PLAYBACK_MAX_ZOOM = 20;
62
+ export const MAX_FRAMING_KEYS = 500;
63
+ export const MAX_SKIPS = 200;
64
+ /** Two keys closer than this are one moment — the later write wins. */
65
+ const SAME_MOMENT = 1e-3;
66
+
67
+ const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
68
+ const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
69
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
70
+
71
+ /** Nothing saved — the row is deleted and the item carries no `playback`. */
72
+ export const isEmptyPlayback = (p: PlaybackEdits | null | undefined): boolean =>
73
+ !p || ((!p.framing || (p.framing.baseZoom === 1 && p.framing.keys.length === 0)) && (!p.skips || p.skips.length === 0));
74
+
75
+ function cleanKey(raw: unknown, duration: number | null): FramingKey | null {
76
+ if (!isObject(raw) || !finite(raw.t) || raw.t < 0) return null;
77
+ if (duration !== null && raw.t > duration) return null;
78
+ const key: FramingKey = { t: raw.t, toNext: raw.toNext === 'glide' ? 'glide' : 'hold' };
79
+ if (isObject(raw.center) && finite(raw.center.x) && finite(raw.center.y))
80
+ key.center = { x: clamp(raw.center.x, 0, 1), y: clamp(raw.center.y, 0, 1) };
81
+ if (finite(raw.zoom)) key.zoom = clamp(raw.zoom, 1, PLAYBACK_MAX_ZOOM);
82
+ return key.center || key.zoom !== undefined ? key : null;
83
+ }
84
+
85
+ /** Sorted by time; keys at one moment merge, the later in the list winning each lane. */
86
+ function tidyKeys(keys: readonly FramingKey[]): FramingKey[] {
87
+ const sorted = keys.map((k, i) => ({ k, i })).sort((a, b) => a.k.t - b.k.t || a.i - b.i);
88
+ const out: FramingKey[] = [];
89
+ for (const { k } of sorted) {
90
+ const last = out[out.length - 1];
91
+ if (last && k.t - last.t < SAME_MOMENT) {
92
+ const merged: FramingKey = { ...last, toNext: k.toNext };
93
+ if (k.center) merged.center = k.center;
94
+ if (k.zoom !== undefined) merged.zoom = k.zoom;
95
+ out[out.length - 1] = merged;
96
+ } else out.push({ ...k });
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /**
102
+ * The ONE gate every writer passes: the server before it stores, the editor before it saves.
103
+ * Out-of-range values are CLAMPED (a center dragged a pixel past the edge is still the edge),
104
+ * keys past the end are dropped, and only what cannot be meant is refused: not an object, too
105
+ * many keys or skips, or skips that remove the whole video.
106
+ *
107
+ * `duration` null means the length is not known: framing still saves, skips are refused —
108
+ * a span cannot be placed in a video of unknown length.
109
+ */
110
+ export function normalizePlayback(
111
+ raw: unknown,
112
+ duration: number | null,
113
+ ): { playback: PlaybackEdits } | { error: string } {
114
+ if (!isObject(raw)) return { error: 'Expected an object of playback edits.' };
115
+ const known = finite(duration) && duration > 0 ? duration : null;
116
+ const playback: PlaybackEdits = {};
117
+
118
+ if (raw.framing !== undefined && raw.framing !== null) {
119
+ if (!isObject(raw.framing)) return { error: 'framing must be an object.' };
120
+ const keysRaw = raw.framing.keys ?? [];
121
+ if (!Array.isArray(keysRaw)) return { error: 'framing.keys must be a list.' };
122
+ if (keysRaw.length > MAX_FRAMING_KEYS) return { error: `At most ${MAX_FRAMING_KEYS} framing keys.` };
123
+ const keys = tidyKeys(keysRaw.map((k) => cleanKey(k, known)).filter((k): k is FramingKey => k !== null));
124
+ const baseZoom = finite(raw.framing.baseZoom) ? clamp(raw.framing.baseZoom, 1, PLAYBACK_MAX_ZOOM) : 1;
125
+ if (baseZoom !== 1 || keys.length > 0) playback.framing = { baseZoom, keys };
126
+ }
127
+
128
+ if (raw.skips !== undefined && raw.skips !== null) {
129
+ if (!Array.isArray(raw.skips)) return { error: 'skips must be a list.' };
130
+ if (raw.skips.length > MAX_SKIPS) return { error: `At most ${MAX_SKIPS} skips.` };
131
+ if (raw.skips.length > 0) {
132
+ if (known === null) return { error: "This video's length is not known yet, so a skip cannot be placed in it." };
133
+ const spans = raw.skips.filter(isObject).map((s) => ({ start: Number(s.start), end: Number(s.end) }));
134
+ const skips = normalizeCuts(spans, known);
135
+ if (skips.length > 0 && keptSegments(skips, known).length === 0)
136
+ return { error: 'Those skips cover the whole video. Leave at least one section to play.' };
137
+ if (skips.length > 0) playback.skips = skips;
138
+ }
139
+ }
140
+ return { playback };
141
+ }
142
+
143
+ /**
144
+ * Where playback must be instead of `t`: the end of the skip `t` is inside, or `null` when `t`
145
+ * plays. Half-open, as every cut: `start` jumps, `end` plays. `skips` must be normalized
146
+ * (sorted, merged), which is what {@link normalizePlayback} stores.
147
+ */
148
+ export function skipTarget(skips: readonly VideoCut[] | undefined, t: number): number | null {
149
+ if (!skips || !finite(t)) return null;
150
+ for (const s of skips) {
151
+ if (t < s.start) return null;
152
+ if (t < s.end) return s.end;
153
+ }
154
+ return null;
155
+ }
156
+
157
+ /** How long the video plays with its skips — the length a tile shows beside its scissors. */
158
+ export function playedSeconds(playback: PlaybackEdits | null | undefined, duration: number): number {
159
+ if (!finite(duration) || duration <= 0) return 0;
160
+ return Math.max(0, duration - cutSeconds(playback?.skips ?? [], duration));
161
+ }
162
+
163
+ // ── The lanes ─────────────────────────────────────────────────────────────────────────────
164
+
165
+ /** The center lane's value at `t`, or `null` when no key sets a center. */
166
+ export function centerAt(track: FramingTrack | null | undefined, t: number): FramingPoint | null {
167
+ const keys = (track?.keys ?? []).filter((k): k is FramingKey & { center: FramingPoint } => !!k.center);
168
+ return laneAt(keys, t, (k) => k.center, (a, b, f) => ({ x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f }));
169
+ }
170
+
171
+ /** The zoom lane's value at `t` — `baseZoom` when no key sets a zoom. */
172
+ export function zoomAt(track: FramingTrack | null | undefined, t: number): number {
173
+ if (!track) return 1;
174
+ const keys = track.keys.filter((k): k is FramingKey & { zoom: number } => k.zoom !== undefined);
175
+ return laneAt(keys, t, (k) => k.zoom, (a, b, f) => Math.exp(Math.log(a) + (Math.log(b) - Math.log(a)) * f)) ?? track.baseZoom;
176
+ }
177
+
178
+ function laneAt<K extends FramingKey, V>(
179
+ keys: readonly K[],
180
+ t: number,
181
+ value: (k: K) => V,
182
+ mix: (a: V, b: V, f: number) => V,
183
+ ): V | null {
184
+ const first = keys[0];
185
+ if (!first) return null;
186
+ if (!(t > first.t)) return value(first);
187
+ for (let i = 0; i < keys.length; i++) {
188
+ const key = keys[i] as K;
189
+ const next = keys[i + 1];
190
+ if (!next) return value(key);
191
+ if (t < next.t) {
192
+ if (key.toNext === 'hold' || next.t <= key.t) return value(key);
193
+ return mix(value(key), value(next), (t - key.t) / (next.t - key.t));
194
+ }
195
+ }
196
+ return value(keys[keys.length - 1] as K);
197
+ }
198
+
199
+ // ── After a real trim ─────────────────────────────────────────────────────────────────────
200
+
201
+ /**
202
+ * The original's edits, moved onto the trimmed copy. `removed` is what the file was CUT BY — the
203
+ * edit row's stored cuts, never the video's current skips, which may have changed since.
204
+ *
205
+ * · A time after a removed span moves back by the removed time before it.
206
+ * · A skip loses the parts the cut removed; the rest moves back. A skip the cut removed is gone.
207
+ * · A framing key inside a removed span is dropped, and at each cut boundary a key carrying the
208
+ * values in effect at the span's END is inserted, so the copy resumes framed exactly as the
209
+ * original did after the same cut (no jump the original did not have).
210
+ */
211
+ export function remapPlayback(
212
+ playback: PlaybackEdits | null | undefined,
213
+ removed: readonly VideoCut[],
214
+ duration: number,
215
+ ): PlaybackEdits {
216
+ if (!playback) return {};
217
+ const cuts = normalizeCuts(removed, duration);
218
+ const keptTotal = duration - cutSeconds(cuts, duration);
219
+ const inside = (t: number) => cuts.some((c) => t >= c.start && t < c.end);
220
+ const mapTime = (t: number) => {
221
+ let shift = 0;
222
+ for (const c of cuts) if (c.end <= t) shift += c.end - c.start;
223
+ return t - shift;
224
+ };
225
+ const out: PlaybackEdits = {};
226
+
227
+ if (playback.skips?.length) {
228
+ const parts: VideoCut[] = [];
229
+ for (const skip of playback.skips) {
230
+ let from = skip.start;
231
+ for (const c of cuts) {
232
+ if (c.end <= from || c.start >= skip.end) continue;
233
+ if (c.start > from) parts.push({ start: mapTime(from), end: mapTime(c.start) });
234
+ from = Math.max(from, c.end);
235
+ }
236
+ if (skip.end - from >= MIN_CUT_SECONDS) parts.push({ start: mapTime(from), end: mapTime(skip.end) });
237
+ }
238
+ const skips = normalizeCuts(parts, keptTotal);
239
+ if (skips.length) out.skips = skips;
240
+ }
241
+
242
+ const framing = playback.framing;
243
+ if (framing && (framing.keys.length || framing.baseZoom !== 1)) {
244
+ const keys: FramingKey[] = framing.keys.filter((k) => !inside(k.t)).map((k) => ({ ...k, t: mapTime(k.t) }));
245
+ const hasCenter = framing.keys.some((k) => k.center);
246
+ const hasZoom = framing.keys.some((k) => k.zoom !== undefined);
247
+ for (const c of cuts) {
248
+ if (c.end >= duration || !(hasCenter || hasZoom)) continue;
249
+ const governing = [...framing.keys].reverse().find((k) => k.t <= c.end);
250
+ const key: FramingKey = { t: mapTime(c.end), toNext: governing?.toNext ?? 'hold' };
251
+ const center = centerAt(framing, c.end);
252
+ if (hasCenter && center) key.center = center;
253
+ if (hasZoom) key.zoom = zoomAt(framing, c.end);
254
+ keys.push(key);
255
+ }
256
+ const tidy = tidyKeys(keys).filter((k) => k.t <= keptTotal);
257
+ if (tidy.length || framing.baseZoom !== 1) out.framing = { baseZoom: framing.baseZoom, keys: tidy };
258
+ }
259
+ return out;
260
+ }
@@ -14,6 +14,7 @@ import {
14
14
  MIN_CUT_SECONDS,
15
15
  normalizeCuts,
16
16
  planCuts,
17
+ planFromCuts,
17
18
  planProblem,
18
19
  planSegments,
19
20
  removeSplit,
@@ -325,3 +326,15 @@ describe('segment keys', () => {
325
326
  expect(after.some((segment) => segment.key === survivor?.key)).toBe(true);
326
327
  });
327
328
  });
329
+
330
+ describe('planFromCuts — the editor reopens on saved skips', () => {
331
+ test('round-trips through planCuts, head and tail cuts included', () => {
332
+ const cuts = [
333
+ { start: 0, end: 3 },
334
+ { start: 20, end: 30 },
335
+ { start: 95, end: 100 },
336
+ ];
337
+ expect(planCuts(planFromCuts(cuts, 100), 100)).toEqual(cuts);
338
+ expect(planFromCuts([], 100)).toEqual({ splits: [], removed: [] });
339
+ });
340
+ });
@@ -353,3 +353,14 @@ export function planCuts(plan: CutPlan, duration: number): VideoCut[] {
353
353
  export function planProblem(plan: CutPlan, duration: number): string | null {
354
354
  return cutListProblem(planCuts(plan, duration), duration);
355
355
  }
356
+
357
+ /**
358
+ * A plan that marks exactly `cuts` — how the editor reopens on a video's SAVED skips (forge task
359
+ * 2026-11-07-23:39). Each cut's bounds become splits and the pieces between them are marked removed,
360
+ * so `planCuts(planFromCuts(c, d), d)` is `normalizeCuts(c, d)`.
361
+ */
362
+ export function planFromCuts(cuts: readonly VideoCut[], duration: number): CutPlan {
363
+ const removed = normalizeCuts(cuts, duration);
364
+ const splits = removed.flatMap((cut) => [cut.start, cut.end]).filter((t) => t > 0 && t < duration);
365
+ return { splits: [...new Set(splits)], removed };
366
+ }
@@ -5,6 +5,7 @@ import {
5
5
  isContentHashed,
6
6
  mayFallBackToShell,
7
7
  type RequestFacts,
8
+ type ShellRoute,
8
9
  resolveShellWorkerConfig,
9
10
  type ShellExtendableEvent,
10
11
  type ShellFetchEvent,
@@ -35,7 +36,7 @@ const facts = (pathname: string, over: Partial<RequestFacts> = {}): RequestFacts
35
36
  });
36
37
 
37
38
  describe('classifyRequest — roms parity', () => {
38
- const table: Array<[string, RequestFacts, string]> = [
39
+ const table: Array<[string, RequestFacts, ShellRoute]> = [
39
40
  ['non-GET is passthrough', facts('/app.js', { method: 'POST' }), 'passthrough'],
40
41
  ['cross-origin is passthrough', facts('/app.js', { sameOrigin: false }), 'passthrough'],
41
42
  ['/api/ is api', facts('/api/saves'), 'api'],
package/src/ctgr/codec.ts CHANGED
@@ -32,8 +32,9 @@ import {
32
32
  const DEFAULT_CHUNK_SIZE = 1800;
33
33
  const DEFAULT_GZIP_THRESHOLD = 1024;
34
34
 
35
- const textEncoder = new TextEncoder();
36
- const textDecoder = new TextDecoder();
35
+ // Pure, so a bundler can leave the codec out of a chunk that only lazy-loads it (forge task 4805).
36
+ const textEncoder = /* @__PURE__ */ new TextEncoder();
37
+ const textDecoder = /* @__PURE__ */ new TextDecoder();
37
38
 
38
39
  /**
39
40
  * Encode a mutating request into one or more `/ctgr<XX>` GET-twin chunks. Small
package/src/ctgr/crc32.ts CHANGED
@@ -3,7 +3,9 @@
3
3
  * native addon. Client and server share this exact implementation so a chunk's
4
4
  * `_crc` integrity check computes the same value on both ends of the tunnel.
5
5
  */
6
- const TABLE = (() => {
6
+ // Marked pure so a bundler drops the table with the codec: a client that lazy-loads the codec
7
+ // (`installCtgrFetch`) must not carry it in its entry chunk (forge task 4805).
8
+ const TABLE = /* @__PURE__ */ (() => {
7
9
  const table = new Uint32Array(256);
8
10
  for (let n = 0; n < 256; n++) {
9
11
  let c = n;
@@ -0,0 +1,111 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { decodeRequest } from '../ctgr/index.js';
3
+ import { installCtgrFetch, ownApiPath, setCtgrMode } from './index.js';
4
+
5
+ /** What reached the network under the shim: the URL and the method, in order. */
6
+ let wire: { url: string; method: string }[] = [];
7
+ const original = window.fetch;
8
+
9
+ beforeAll(() => {
10
+ // The "network": records what it was handed and answers 200 with the URL it saw.
11
+ window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
12
+ const url = input instanceof Request ? input.url : String(input);
13
+ const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
14
+ wire.push({ url, method });
15
+ return new Response(JSON.stringify({ url }), { status: 200 });
16
+ }) as typeof window.fetch;
17
+ installCtgrFetch();
18
+ });
19
+
20
+ afterAll(() => {
21
+ window.fetch = original;
22
+ });
23
+
24
+ beforeEach(() => {
25
+ wire = [];
26
+ localStorage.clear();
27
+ });
28
+
29
+ const decodedTwin = async (url: string) => {
30
+ const decoded = await decodeRequest(new URL(url, 'http://x').searchParams);
31
+ if (decoded === 'partial') throw new Error('expected a single-chunk twin');
32
+ return decoded;
33
+ };
34
+
35
+ describe('installCtgrFetch — every own-API mutation takes the GET tunnel', () => {
36
+ test('mode OFF: a POST goes out as the POST it is', async () => {
37
+ await fetch('/api/plays', { method: 'POST', body: JSON.stringify({ id: 1 }) });
38
+ expect(wire).toEqual([{ url: '/api/plays', method: 'POST' }]);
39
+ });
40
+
41
+ test('mode ON: a POST leaves as its GET twin, carrying the body and the query', async () => {
42
+ setCtgrMode(true);
43
+ const res = await fetch('/api/plays?x=1', {
44
+ method: 'POST',
45
+ headers: { 'content-type': 'application/json' },
46
+ body: JSON.stringify({ id: 1 }),
47
+ });
48
+ expect(res.status).toBe(200);
49
+ expect(wire).toHaveLength(1);
50
+ expect(wire[0]?.method).toBe('GET');
51
+ expect(wire[0]?.url.startsWith('/ctgrpo/api/plays?x=1&')).toBe(true);
52
+ const decoded = await decodedTwin(wire[0]?.url ?? '');
53
+ expect(decoded.method).toBe('POST');
54
+ expect(decoded.body).toEqual({ id: 1 });
55
+ });
56
+
57
+ test('mode ON: a Request object is read for its method and body', async () => {
58
+ setCtgrMode(true);
59
+ const url = new URL('/api/playlists/7', window.location.href).href;
60
+ await fetch(new Request(url, { method: 'PATCH', body: JSON.stringify({ name: 'b' }) }));
61
+ expect(wire[0]?.method).toBe('GET');
62
+ const decoded = await decodedTwin(wire[0]?.url ?? '');
63
+ expect(decoded.method).toBe('PATCH');
64
+ expect(decoded.body).toEqual({ name: 'b' });
65
+ });
66
+
67
+ test('mode ON: GETs, other origins and paths outside /api/ pass through untouched', async () => {
68
+ setCtgrMode(true);
69
+ await fetch('/api/library');
70
+ await fetch('https://media.example.com/api/upload', { method: 'POST', body: 'x' });
71
+ await fetch('/cdn-cgi/rum', { method: 'POST', body: 'x' });
72
+ expect(wire).toEqual([
73
+ { url: '/api/library', method: 'GET' },
74
+ { url: 'https://media.example.com/api/upload', method: 'POST' },
75
+ { url: '/cdn-cgi/rum', method: 'POST' },
76
+ ]);
77
+ });
78
+ });
79
+
80
+ describe('ownApiPath', () => {
81
+ const base = 'https://music.example.com/library';
82
+ test('a same-origin /api/ path, absolute or relative, keeps its query', () => {
83
+ expect(ownApiPath('/api/a?b=1', base)).toBe('/api/a?b=1');
84
+ expect(ownApiPath('https://music.example.com/api/a', base)).toBe('/api/a');
85
+ });
86
+ test('another origin, or a path the app does not own, is null', () => {
87
+ expect(ownApiPath('https://auth.example.com/api/a', base)).toBeNull();
88
+ expect(ownApiPath('/ctgrpo/api/a', base)).toBeNull();
89
+ expect(ownApiPath('/assets/x.js', base)).toBeNull();
90
+ });
91
+ });
92
+
93
+ describe('the codec stays out of an entry chunk that only boots the mode (forge task 4805)', () => {
94
+ const read = (rel: string) => Bun.file(new URL(rel, import.meta.url)).text();
95
+
96
+ test('ctgrFetch reaches the converter and the sender only through import()', async () => {
97
+ const src = await read('./ctgrFetch.ts');
98
+ expect(src).not.toMatch(/^import [^;]*from '\.\/(convertRequest|sendRequests)\.js'/m);
99
+ expect(src).toContain("import('./convertRequest.js')");
100
+ expect(src).toContain("import('./sendRequests.js')");
101
+ });
102
+
103
+ test('every top-level `new` or IIFE in the codec is marked pure, so a bundler can drop it', async () => {
104
+ for (const rel of ['../ctgr/codec.ts', '../ctgr/crc32.ts']) {
105
+ const offenders = (await read(rel))
106
+ .split('\n')
107
+ .filter((line) => /^(const|let) \w+ = (new |\()/.test(line));
108
+ expect({ rel, offenders }).toEqual({ rel, offenders: [] });
109
+ }
110
+ });
111
+ });
@@ -0,0 +1,88 @@
1
+ /*
2
+ * GET-only mode for a whole site in ONE boot call: `installCtgrFetch()`.
3
+ *
4
+ * auth and desk route each request through `convertIfOnlyGETRequests` + `sendCtgrRequests`
5
+ * by hand, inside their own API client. An app whose mutations are spread over dozens of
6
+ * `fetch` call sites (music, 2026-11-09: the owner opened it with `?ctgr=1` on a machine whose
7
+ * network refuses every non-GET, and nothing in it went through the tunnel) would need each
8
+ * one rewritten. This wraps `window.fetch` instead: while the device's mode is on, a mutating
9
+ * request to this origin's own `/api/…` leaves as its `/ctgr<xx>` GET twin(s) and answers with
10
+ * the one real response; everything else — a GET, another origin, a path the app does not own
11
+ * — passes through untouched. With the mode off it is a pass-through.
12
+ *
13
+ * 🔴 It arms the guard FIRST and wraps it, never the other way round: wrapped by the guard,
14
+ * a POST would be refused before it could be converted. The server half is `mountCtgrTunnel`
15
+ * (`cursedbelt-server/ctgr-tunnel`), which re-dispatches each twin into the real route.
16
+ */
17
+ import { isCtgrModeOn } from './ctgrConfig.js';
18
+ import { installCtgrGuard } from './ctgrGuard.js';
19
+
20
+ /**
21
+ * The codec (gzip, sha-256, crc, base64url) loads only on a device that is in the mode: in an
22
+ * app's entry chunk it cost every visitor ~4.5 kB gzip (music, measured 2026-11-09) for a path
23
+ * almost none of them take. A bundler splits it out at this `import()`.
24
+ */
25
+ const loadTunnel = () =>
26
+ Promise.all([import('./convertRequest.js'), import('./sendRequests.js')]).then(([c, s]) => ({
27
+ convertIfOnlyGETRequests: c.convertIfOnlyGETRequests,
28
+ sendCtgrRequests: s.sendCtgrRequests,
29
+ }));
30
+
31
+ const SAFE_METHODS = new Set(['GET', 'HEAD']);
32
+
33
+ /**
34
+ * The same-origin `/api/…` path (with its query) a URL names, or null when it is another
35
+ * origin or a path the request layer does not own (Cloudflare's `/cdn-cgi/rum` beacon, an
36
+ * asset, a twin that is already converted).
37
+ */
38
+ export const ownApiPath = (rawUrl: string, base: string): string | null => {
39
+ try {
40
+ const u = new URL(rawUrl, base);
41
+ if (u.origin !== new URL(base).origin) return null;
42
+ return u.pathname.startsWith('/api/') ? `${u.pathname}${u.search}` : null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ };
47
+
48
+ /** A `Request`'s body as something the converter carries: its form, or its text. */
49
+ const bodyOfRequest = async (request: Request): Promise<BodyInit | undefined> => {
50
+ if (!request.body) return undefined;
51
+ const type = request.headers.get('content-type') ?? '';
52
+ return type.includes('multipart/form-data') ? request.clone().formData() : request.clone().text();
53
+ };
54
+
55
+ let installed = false;
56
+
57
+ /**
58
+ * Arm the guard, then route every same-origin `/api/` mutation through the GET tunnel while
59
+ * the mode is on. Call once at boot, after `applyCtgrFromLocation()` and before anything
60
+ * fetches. Idempotent; a no-op with no `window.fetch`.
61
+ */
62
+ export const installCtgrFetch = (): void => {
63
+ if (installed || typeof window === 'undefined' || typeof window.fetch !== 'function') return;
64
+ installed = true;
65
+ installCtgrGuard();
66
+ const inner = window.fetch.bind(window);
67
+ const tunnelled = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
68
+ if (!isCtgrModeOn()) return inner(input, init);
69
+ const request = input instanceof Request ? input : null;
70
+ const method = (init?.method ?? request?.method ?? 'GET').toUpperCase();
71
+ if (SAFE_METHODS.has(method)) return inner(input, init);
72
+ const href = request ? request.url : input instanceof URL ? input.href : String(input);
73
+ const path = ownApiPath(href, window.location.href);
74
+ if (path === null) return inner(input, init);
75
+
76
+ const effective: RequestInit = { ...init, method };
77
+ if (request) {
78
+ effective.headers ??= request.headers;
79
+ effective.credentials ??= request.credentials;
80
+ effective.signal ??= request.signal;
81
+ if (init?.body === undefined) effective.body = await bodyOfRequest(request);
82
+ }
83
+ const { convertIfOnlyGETRequests, sendCtgrRequests } = await loadTunnel();
84
+ const { requests } = await convertIfOnlyGETRequests(path, effective);
85
+ return sendCtgrRequests(requests, (twin) => inner(twin.url, twin.init));
86
+ };
87
+ window.fetch = tunnelled as typeof window.fetch;
88
+ };
@@ -9,7 +9,9 @@
9
9
  * The wire codec is `cursedbelt-core/ctgr`; the server that decodes the twins is
10
10
  * `cursedbelt-server` (`mountCtgrTunnel`). A site opts in with two boot calls —
11
11
  * `applyCtgrFromLocation()` then `installCtgrGuard()` — and routes its requests
12
- * through `convertIfOnlyGETRequests`, then `sendCtgrRequests`.
12
+ * through `convertIfOnlyGETRequests`, then `sendCtgrRequests`. A site whose mutations
13
+ * are spread over many `fetch` calls calls `installCtgrFetch()` instead of the guard,
14
+ * and every same-origin `/api/` mutation takes the tunnel with no call site changed.
13
15
  */
14
16
  export {
15
17
  applyCtgrFromLocation,
@@ -19,6 +21,7 @@ export {
19
21
  setCtgrMode,
20
22
  } from './ctgrConfig.js';
21
23
  export { type CtgrFetchRequest, convertIfOnlyGETRequests } from './convertRequest.js';
24
+ export { installCtgrFetch, ownApiPath } from './ctgrFetch.js';
22
25
  export { installCtgrGuard } from './ctgrGuard.js';
23
26
  export {
24
27
  CTGR_INCOMPLETE_STATUS,