cursedbelt-core 2.14.1 → 2.15.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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-core",
3
- "version": "2.14.1",
3
+ "version": "2.15.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'],