cursedbelt 5.77.0 → 5.78.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.
Files changed (56) hide show
  1. package/dist/react/canvas-hud/CanvasToolbar.d.ts +1 -1
  2. package/dist/react/components/DataTable/TableFilterBuilder.d.ts.map +1 -1
  3. package/dist/react/components/DataTable/TableFilterBuilder.js +2 -2
  4. package/dist/react/components/DataTable/TableFilterBuilder.js.map +1 -1
  5. package/dist/react/components/ListToolbar.d.ts +2 -2
  6. package/dist/react/components/ListToolbar.d.ts.map +1 -1
  7. package/dist/react/components/ListToolbar.js +1 -1
  8. package/dist/react/hooks/useOutsidePress.d.ts.map +1 -1
  9. package/dist/react/hooks/useOutsidePress.js +18 -3
  10. package/dist/react/hooks/useOutsidePress.js.map +1 -1
  11. package/dist/react/hooks/useScrollMemory.d.ts +4 -3
  12. package/dist/react/hooks/useScrollMemory.d.ts.map +1 -1
  13. package/dist/react/hooks/useScrollMemory.js +16 -2
  14. package/dist/react/hooks/useScrollMemory.js.map +1 -1
  15. package/dist/react/media/FocusViewer.d.ts +8 -1
  16. package/dist/react/media/FocusViewer.d.ts.map +1 -1
  17. package/dist/react/media/FocusViewer.js +174 -10
  18. package/dist/react/media/FocusViewer.js.map +1 -1
  19. package/dist/react/media/focusViewer.css +31 -0
  20. package/dist/react/media/gifClip.d.ts +46 -0
  21. package/dist/react/media/gifClip.d.ts.map +1 -0
  22. package/dist/react/media/gifClip.js +74 -0
  23. package/dist/react/media/gifClip.js.map +1 -0
  24. package/dist/react/media/gifEncode.d.ts +46 -0
  25. package/dist/react/media/gifEncode.d.ts.map +1 -0
  26. package/dist/react/media/gifEncode.js +288 -0
  27. package/dist/react/media/gifEncode.js.map +1 -0
  28. package/dist/react/media/gifRecord.d.ts +26 -0
  29. package/dist/react/media/gifRecord.d.ts.map +1 -0
  30. package/dist/react/media/gifRecord.js +93 -0
  31. package/dist/react/media/gifRecord.js.map +1 -0
  32. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  33. package/dist/styles-areas/analytics.css +9 -9
  34. package/dist/styles-areas/data-table.css +10 -10
  35. package/dist/styles-areas/media.css +2 -2
  36. package/package.json +3 -3
  37. package/scripts/guardrailsEnforce.spec.ts +10 -0
  38. package/scripts/guardrailsEnforce.ts +4 -1
  39. package/scripts/styleAreas.ts +3 -1
  40. package/src/react/canvas-hud/CanvasToolbar.tsx +1 -1
  41. package/src/react/components/DataTable/TableFilterBuilder.tsx +9 -13
  42. package/src/react/components/DataTable/tableSystem.spec.tsx +15 -7
  43. package/src/react/components/ListToolbar.tsx +2 -2
  44. package/src/react/hooks/useOutsidePress.spec.tsx +20 -0
  45. package/src/react/hooks/useOutsidePress.ts +17 -2
  46. package/src/react/hooks/useScrollMemory.spec.tsx +45 -2
  47. package/src/react/hooks/useScrollMemory.ts +20 -5
  48. package/src/react/media/FocusViewer.tsx +322 -25
  49. package/src/react/media/focusViewer.css +31 -0
  50. package/src/react/media/gifClip.ts +90 -0
  51. package/src/react/media/gifEncode.spec.ts +213 -0
  52. package/src/react/media/gifEncode.ts +298 -0
  53. package/src/react/media/gifRecord.ts +103 -0
  54. package/src/styles-areas/analytics.css +9 -9
  55. package/src/styles-areas/data-table.css +10 -10
  56. package/src/styles-areas/media.css +2 -2
@@ -151,14 +151,17 @@ export interface ScrollMemoryOptions {
151
151
  */
152
152
  enabled?: boolean;
153
153
  /**
154
- * How long a restore waits for the page to grow tall enough before settling as close as it
155
- * got. Default 3000ms — a reload, where only the first page of a list exists, needs a few
156
- * pages' round trips.
154
+ * How long a restore waits for the page to GROW before settling as close as it got — counted
155
+ * from the last time it grew, so a list paging itself down keeps being followed (up to 20s in
156
+ * all). Default 3000ms — a reload, where only the first page of a list exists, needs a few
157
+ * pages' round trips, each of which must land within it.
157
158
  */
158
159
  deadlineMs?: number;
159
160
  }
160
161
 
161
162
  const RESTORE_DEADLINE_MS = 3_000;
163
+ /** The longest a restore keeps following a page that is still growing — the viewer can always take it back sooner. */
164
+ const RESTORE_CEILING_MS = 20_000;
162
165
  /**
163
166
  * How long a landed restore holds its target against a scroll the viewer did not make. Long
164
167
  * enough for a list to finish settling (the late move in forge task 2784 came inside 800ms, under
@@ -271,7 +274,14 @@ function restore(
271
274
  deadlineMs: number,
272
275
  settled: (outcome: RestoreOutcome) => void,
273
276
  ): () => void {
274
- const deadline = performance.now() + deadlineMs;
277
+ // 🔴 The deadline is for a page that has STOPPED growing, not a stopwatch from the start (forge
278
+ // task 3069): flix's phone grid, reloaded and coming back to 30,000px, pages itself down one round
279
+ // trip at a time, and with eight browsers sharing the Mac those trips took longer than 3s in all.
280
+ // The restore gave up at 29,731px while the pages were still arriving — a slow phone network is
281
+ // the same case. Each time the page grows the wait starts again, up to RESTORE_CEILING_MS.
282
+ const started = performance.now();
283
+ let deadline = started + deadlineMs;
284
+ let lastRoom = -1;
275
285
  let frame = 0;
276
286
  let holdTimer: ReturnType<typeof setTimeout> | undefined;
277
287
  let phase: 'waiting' | 'holding' | 'done' = 'waiting';
@@ -306,7 +316,12 @@ function restore(
306
316
  }
307
317
  // Short of it: go as far as the page allows — that is what asks an infinite list for more.
308
318
  scroller.set(Math.max(0, room));
309
- if (performance.now() >= deadline) {
319
+ const now = performance.now();
320
+ if (room > lastRoom) {
321
+ lastRoom = room;
322
+ deadline = Math.min(now + deadlineMs, started + Math.max(deadlineMs, RESTORE_CEILING_MS));
323
+ }
324
+ if (now >= deadline) {
310
325
  finish();
311
326
  settled('deadline');
312
327
  } else frame = requestAnimationFrame(attempt);
@@ -17,6 +17,10 @@ import "./focusViewer.css";
17
17
  * which is the correct surface for a host with nowhere to put one.
18
18
  * · `destination` — an arbitrary node in the action row, for a host that wants to choose
19
19
  * WHERE (collections offers its folder picker there; binary-server offers nothing).
20
+ * · `onSaveGif` — where a looping GIF of a section goes. Unlike a snapshot, a GIF is
21
+ * offered to EVERY host: without a store it downloads to the device (owner, 2026-10-11:
22
+ * *"In collections and the cb media player in general allow making a looping gif out of a
23
+ * section of a video"* — "in general" is binary-server too, which keeps no stills).
20
24
  * Everything else — geometry, gestures, keyboard, the canvas capture — is the same code for
21
25
  * every consumer, which is the entire point of the lift.
22
26
  *
@@ -50,7 +54,7 @@ import "./focusViewer.css";
50
54
  import { useCallback, useEffect, useRef, useState } from "react";
51
55
  import { Alert } from "../components/Alert.js";
52
56
  import { Button } from "../components/Button.js";
53
- import { NativeSelect } from "../components/NativeSelect.js";
57
+ import { SelectField } from "../components/SelectField.js";
54
58
  import { Tooltip } from "../components/Tooltip.js";
55
59
  import { MediaResizeBar, MediaResizeCorner, useMediaResize } from "./MediaResize.js";
56
60
  import { FOCUS_HEIGHT_KEY } from "./stageResize.js";
@@ -58,6 +62,17 @@ import { PictureControls } from "./PictureControls.js";
58
62
  import { handOffPlayhead } from "./playheadHandoff.js";
59
63
  import { isPlayPauseSpace } from "./playerSpaceKey.js";
60
64
  import { PLAYBACK_RATES } from "./VideoPlayer.js";
65
+ import {
66
+ clampGifSection,
67
+ formatGifBytes,
68
+ formatGifTime,
69
+ GIF_FRAME_RATES,
70
+ GIF_WIDTHS,
71
+ type GifSection,
72
+ gifFileName,
73
+ gifSectionAt,
74
+ MAX_GIF_SECONDS,
75
+ } from "./gifClip.js";
61
76
  import {
62
77
  FULL_REGION,
63
78
  MAX_ZOOM,
@@ -87,6 +102,20 @@ import {
87
102
  } from "./videoEnhance.js";
88
103
  import { cn, RAW_BUTTON } from '../lib/cn.js';
89
104
 
105
+ /**
106
+ * The GIF recorder and its encoder, fetched the first time the GIF panel opens — they are this
107
+ * feature's bytes, and a gallery that never makes a GIF should not carry them. Kept once
108
+ * fetched; a failed fetch is forgotten so the next press asks again.
109
+ */
110
+ let gifRecorder: Promise<typeof import("./gifRecord.js")> | null = null;
111
+ function loadGifRecorder(): Promise<typeof import("./gifRecord.js")> {
112
+ gifRecorder ??= import("./gifRecord.js").catch((error: unknown) => {
113
+ gifRecorder = null;
114
+ throw error;
115
+ });
116
+ return gifRecorder;
117
+ }
118
+
90
119
  /** `m:ss`, or `h:mm:ss` past the hour. Local so this module has no app dependency. */
91
120
  function formatDuration(seconds: number): string {
92
121
  if (!Number.isFinite(seconds) || seconds < 0) return "";
@@ -148,6 +177,16 @@ export interface FocusViewerProps {
148
177
  ) => Promise<FocusSaveResult | void> | FocusSaveResult | void;
149
178
  /** An arbitrary control in the action row — collections puts its folder picker here. */
150
179
  destination?: React.ReactNode;
180
+ /**
181
+ * Keep a looping GIF of a section. OMIT IT and "Make a GIF" still works — it downloads the
182
+ * file instead, because a GIF needs no store to be useful the way a filed still does.
183
+ * `destination` applies to it as it does to a snapshot.
184
+ */
185
+ onSaveGif?: (
186
+ gif: Blob,
187
+ meta: GifSection,
188
+ // biome-ignore lint/suspicious/noConfusingVoidType: as `onSaveSnapshot` — `() => {}` is the common handler
189
+ ) => Promise<FocusSaveResult | void> | FocusSaveResult | void;
151
190
  /**
152
191
  * How tall, in CSS pixels, the picture in the host's own player was when this opened.
153
192
  *
@@ -183,6 +222,7 @@ export function FocusViewer({
183
222
  snapshots = [],
184
223
  onSaveSnapshot,
185
224
  destination,
225
+ onSaveGif,
186
226
  matchHeight,
187
227
  videoId,
188
228
  onClose,
@@ -232,6 +272,19 @@ export function FocusViewer({
232
272
  const [busy, setBusy] = useState(false);
233
273
  const [error, setError] = useState<string | null>(null);
234
274
  const [note, setNote] = useState<string | null>(null);
275
+ /*
276
+ * ── A section, as a looping GIF ────────────────────────────────────────────────────
277
+ * Null while the GIF panel is shut. Open, it is the section the GIF will hold, and while
278
+ * `loopSection` is on the picture plays only that section, round and round — the preview
279
+ * IS the GIF, before a frame is encoded.
280
+ */
281
+ const [gif, setGif] = useState<GifSection | null>(null);
282
+ const [loopSection, setLoopSection] = useState(true);
283
+ const [gifFps, setGifFps] = useState<(typeof GIF_FRAME_RATES)[number]>(15);
284
+ const [gifWidth, setGifWidth] = useState<(typeof GIF_WIDTHS)[number]>(480);
285
+ /** `[done, total]` frames while one is being made. */
286
+ const [gifProgress, setGifProgress] = useState<[number, number] | null>(null);
287
+ const gifAbort = useRef<AbortController | null>(null);
235
288
 
236
289
 
237
290
  /**
@@ -362,6 +415,110 @@ export function FocusViewer({
362
415
  if (wasPlaying) void video.play().catch(() => undefined);
363
416
  }, []);
364
417
 
418
+ /**
419
+ * Play the GIF's section on a loop while it is being chosen.
420
+ *
421
+ * Checked every animation frame rather than on `timeupdate`, which fires about four times
422
+ * a second and would let the picture run a quarter-second past the end before snapping
423
+ * back — a preview that shows frames the GIF will not have. Only while PLAYING: a paused
424
+ * scrub or frame-step outside the section is the person looking for a new end, and must not
425
+ * be yanked back. Stands down while a GIF is being made, whose seeks are its own.
426
+ */
427
+ useEffect(() => {
428
+ if (!gif || !loopSection || gifProgress) return;
429
+ let raf = 0;
430
+ const check = (): void => {
431
+ const video = videoRef.current;
432
+ if (video && !video.paused && !scrubbing.current) {
433
+ const t = video.currentTime;
434
+ if (t >= gif.endSeconds || t < gif.startSeconds - 0.05) video.currentTime = gif.startSeconds;
435
+ }
436
+ raf = requestAnimationFrame(check);
437
+ };
438
+ raf = requestAnimationFrame(check);
439
+ return () => cancelAnimationFrame(raf);
440
+ }, [gif, loopSection, gifProgress]);
441
+
442
+ /** Close the GIF panel, stopping a GIF still being made. */
443
+ const closeGif = (): void => {
444
+ gifAbort.current?.abort();
445
+ setGif(null);
446
+ };
447
+
448
+ /** Mark where the GIF starts or ends at the playhead — the `I` and `O` keys, as in an editor. */
449
+ const markGif = (end: "start" | "end"): void => {
450
+ const at = videoRef.current?.currentTime ?? position;
451
+ const total = duration || videoRef.current?.duration || 0;
452
+ // Fetched while the section is chosen, so "Save GIF" does not wait on the network.
453
+ void loadGifRecorder().catch(() => undefined);
454
+ setGif((current) => {
455
+ if (!current) return gifSectionAt(at, total);
456
+ const length = current.endSeconds - current.startSeconds;
457
+ return end === "start"
458
+ ? // A start past the old end keeps the section's length rather than collapsing it.
459
+ clampGifSection(
460
+ { startSeconds: at, endSeconds: at >= current.endSeconds ? at + length : current.endSeconds },
461
+ total,
462
+ "start",
463
+ )
464
+ : clampGifSection({ startSeconds: current.startSeconds, endSeconds: at }, total, "end");
465
+ });
466
+ };
467
+
468
+ /**
469
+ * Make the GIF: seek through the section, encode what is ON SCREEN — the zoomed area and
470
+ * the picture adjustments, exactly as a snapshot takes them — and save or download it.
471
+ */
472
+ const makeGif = async (how: "save" | "download"): Promise<void> => {
473
+ const video = videoRef.current;
474
+ if (!video || !gif) return;
475
+ const abort = new AbortController();
476
+ gifAbort.current = abort;
477
+ setError(null);
478
+ setNote(null);
479
+ setGifProgress([0, 1]);
480
+ try {
481
+ const crop = regionPixels(shown, { width: video.videoWidth, height: video.videoHeight });
482
+ if (!crop) throw new Error("The video has not decoded a frame yet.");
483
+ // 🔴 A failed fetch here is almost always a deploy since this page opened — the chunk
484
+ // was renamed — and says so, rather than reloading away the section being made.
485
+ const { recordGifClip } = await loadGifRecorder().catch(() => {
486
+ throw new Error("This app was updated since the page opened — reload it to make a GIF.");
487
+ });
488
+ const blob = await recordGifClip(video, {
489
+ ...gif,
490
+ fps: gifFps,
491
+ width: gifWidth,
492
+ crop,
493
+ ...(isEnhanced(enhance) ? { filter: filterString(enhance) } : {}),
494
+ onProgress: (done, total) => setGifProgress([done, total]),
495
+ signal: abort.signal,
496
+ });
497
+ const weight = formatGifBytes(blob.size);
498
+ if (how === "save" && onSaveGif) {
499
+ const saved = (await onSaveGif(blob, gif)) ?? {};
500
+ setNote(
501
+ saved.message
502
+ ? `${saved.message} (${weight} GIF)`
503
+ : saved.duplicate
504
+ ? "That exact GIF was already saved — nothing added."
505
+ : `GIF saved — ${weight}.`,
506
+ );
507
+ } else {
508
+ downloadBlob(blob, gifFileName(title, gif.startSeconds));
509
+ setNote(`GIF downloaded — ${weight}.`);
510
+ }
511
+ } catch (err) {
512
+ if (err instanceof DOMException && err.name === "AbortError") setNote("GIF cancelled.");
513
+ else setError(err instanceof Error ? err.message : "The GIF could not be made.");
514
+ } finally {
515
+ if (gifAbort.current === abort) gifAbort.current = null;
516
+ setGifProgress(null);
517
+ }
518
+ };
519
+ // A GIF still being made is stopped when the viewer goes.
520
+ useEffect(() => () => gifAbort.current?.abort(), []);
521
+
365
522
  /**
366
523
  * Drag the timeline to skip around LIVE.
367
524
  *
@@ -607,7 +764,10 @@ export function FocusViewer({
607
764
  event.shiftKey
608
765
  )
609
766
  return;
610
- if (event.key === "ArrowRight") {
767
+ if (gif && !gifProgress && (event.key === "i" || event.key === "o")) {
768
+ event.preventDefault();
769
+ markGif(event.key === "i" ? "start" : "end");
770
+ } else if (event.key === "ArrowRight") {
611
771
  event.preventDefault();
612
772
  stepFrame(1);
613
773
  } else if (event.key === "ArrowLeft") {
@@ -797,23 +957,18 @@ export function FocusViewer({
797
957
  motion is a primary gesture in this viewer, not a preference. */}
798
958
  <label className="inspector__speed" htmlFor="inspector-speed">
799
959
  <span className="sr-only">Playback speed</span>
800
- <NativeSelect
960
+ <SelectField
801
961
  id="inspector-speed"
802
962
  className="w-auto"
803
- onChange={(event) => {
804
- const next = Number(event.target.value);
963
+ onValueChange={(value) => {
964
+ const next = Number(value);
805
965
  setRate(next);
806
966
  if (videoRef.current) videoRef.current.playbackRate = next;
807
967
  }}
968
+ options={PLAYBACK_RATES.map((option) => ({ value: String(option), label: `${option}×` }))}
808
969
  size="sm"
809
- value={rate}
810
- >
811
- {PLAYBACK_RATES.map((option) => (
812
- <option key={option} value={option}>
813
- {option}×
814
- </option>
815
- ))}
816
- </NativeSelect>
970
+ value={String(rate)}
971
+ />
817
972
  </label>
818
973
  </div>
819
974
 
@@ -911,25 +1066,29 @@ export function FocusViewer({
911
1066
  {onSaveSnapshot ? (
912
1067
  <label className="picture__slider" htmlFor="inspector-upscale">
913
1068
  <span>Save at</span>
914
- <NativeSelect
1069
+ <SelectField
915
1070
  id="inspector-upscale"
916
1071
  className="w-auto"
917
- onChange={(event) => setUpscale(Number(event.target.value))}
1072
+ onValueChange={(value) => setUpscale(Number(value))}
1073
+ options={UPSCALE_FACTORS.map((factor) => ({
1074
+ value: String(factor),
1075
+ label: `${factor}× ${factor === 1 ? "(as-is)" : "upscaled"}`,
1076
+ }))}
918
1077
  size="sm"
919
- value={upscale}
920
- >
921
- {UPSCALE_FACTORS.map((factor) => (
922
- <option key={factor} value={factor}>
923
- {factor}× {factor === 1 ? "(as-is)" : "upscaled"}
924
- </option>
925
- ))}
926
- </NativeSelect>
1078
+ value={String(upscale)}
1079
+ />
927
1080
  </label>
928
1081
  ) : null}
929
1082
  </PictureControls>
930
1083
 
931
- {onSaveSnapshot ? (
1084
+ {/*
1085
+ * 🔴 The save row is drawn for EVERY host now, because "Make a GIF" is: a host with no
1086
+ * store downloads the GIF instead (see `onSaveGif`). Only the snapshot buttons still
1087
+ * need `onSaveSnapshot`.
1088
+ */}
932
1089
  <div className="inspector__save">
1090
+ {onSaveSnapshot ? (
1091
+ <>
933
1092
  <Button
934
1093
  disabled={busy}
935
1094
  onClick={() => void takeSnapshot(false)}
@@ -952,8 +1111,132 @@ export function FocusViewer({
952
1111
  Snapshot whole frame
953
1112
  </Button>
954
1113
  )}
955
- {destination}
1114
+ </>
1115
+ ) : null}
1116
+ <Button
1117
+ aria-expanded={gif !== null}
1118
+ disabled={gifProgress !== null}
1119
+ onClick={() => (gif ? closeGif() : markGif("start"))}
1120
+ tooltip={
1121
+ gif
1122
+ ? "Put the GIF away"
1123
+ : "Turn a few seconds of this video into a looping GIF, starting here"
1124
+ }
1125
+ variant={gif ? "primary" : "secondary"}
1126
+ >
1127
+ Make a GIF
1128
+ </Button>
1129
+ {onSaveSnapshot || onSaveGif ? destination : null}
956
1130
  </div>
1131
+
1132
+ {gif ? (
1133
+ <section aria-label="Make a GIF" className="inspector__gif">
1134
+ <div className="inspector__gifrange">
1135
+ <Button
1136
+ disabled={gifProgress !== null}
1137
+ onClick={() => markGif("start")}
1138
+ tooltip="Start the GIF at the playhead (I)"
1139
+ variant="secondary"
1140
+ >
1141
+ Start here
1142
+ </Button>
1143
+ <span className="inspector__gifspan">
1144
+ <span>{formatGifTime(gif.startSeconds)}</span>
1145
+ <span aria-hidden="true">–</span>
1146
+ <span>{formatGifTime(gif.endSeconds)}</span>
1147
+ <span className="inspector__time">
1148
+ {(gif.endSeconds - gif.startSeconds).toFixed(1)} s
1149
+ {gif.endSeconds - gif.startSeconds >= MAX_GIF_SECONDS - 0.05 ? " · the longest a GIF can be" : ""}
1150
+ </span>
1151
+ </span>
1152
+ <Button
1153
+ disabled={gifProgress !== null}
1154
+ onClick={() => markGif("end")}
1155
+ tooltip={`End the GIF at the playhead (O) — up to ${MAX_GIF_SECONDS} s`}
1156
+ variant="secondary"
1157
+ >
1158
+ End here
1159
+ </Button>
1160
+ <Button
1161
+ aria-pressed={loopSection}
1162
+ disabled={gifProgress !== null}
1163
+ onClick={() => setLoopSection((on) => !on)}
1164
+ tooltip={
1165
+ loopSection
1166
+ ? "Playing only the section, on a loop — press to play the whole video again"
1167
+ : "Play only the section, on a loop, the way the GIF will"
1168
+ }
1169
+ variant={loopSection ? "primary" : "ghost"}
1170
+ >
1171
+ Loop section
1172
+ </Button>
1173
+ </div>
1174
+ <div className="inspector__gifout">
1175
+ {/* SelectFields, like "Save at" beside them: the viewer already carries the
1176
+ one, and a segmented strip would pull Radix Tabs into every gallery. */}
1177
+ <label className="picture__slider" htmlFor="inspector-gif-fps">
1178
+ <span>Smoothness</span>
1179
+ <SelectField
1180
+ id="inspector-gif-fps"
1181
+ className="w-auto"
1182
+ disabled={gifProgress !== null}
1183
+ onValueChange={(value) => setGifFps(Number(value) as (typeof GIF_FRAME_RATES)[number])}
1184
+ options={GIF_FRAME_RATES.map((fps) => ({ value: String(fps), label: `${fps} fps` }))}
1185
+ size="sm"
1186
+ value={String(gifFps)}
1187
+ />
1188
+ </label>
1189
+ <label className="picture__slider" htmlFor="inspector-gif-width">
1190
+ <span>Width</span>
1191
+ <SelectField
1192
+ id="inspector-gif-width"
1193
+ className="w-auto"
1194
+ disabled={gifProgress !== null}
1195
+ onValueChange={(value) => setGifWidth(Number(value) as (typeof GIF_WIDTHS)[number])}
1196
+ options={GIF_WIDTHS.map((width) => ({ value: String(width), label: `${width} px` }))}
1197
+ size="sm"
1198
+ value={String(gifWidth)}
1199
+ />
1200
+ </label>
1201
+ {gifProgress ? (
1202
+ <>
1203
+ <span aria-live="polite" className="inspector__time">
1204
+ Making the GIF — frame {gifProgress[0]} of {gifProgress[1]}
1205
+ </span>
1206
+ <Button onClick={() => gifAbort.current?.abort()} tooltip="Stop making this GIF" variant="ghost">
1207
+ Cancel
1208
+ </Button>
1209
+ </>
1210
+ ) : (
1211
+ <>
1212
+ {onSaveGif ? (
1213
+ <Button
1214
+ onClick={() => void makeGif("save")}
1215
+ tooltip={
1216
+ isFullRegion(region)
1217
+ ? "Save the section as a looping GIF beside this video"
1218
+ : "Save the section as a looping GIF — just the part you are looking at"
1219
+ }
1220
+ variant="primary"
1221
+ >
1222
+ Save GIF
1223
+ </Button>
1224
+ ) : null}
1225
+ <Button
1226
+ onClick={() => void makeGif("download")}
1227
+ tooltip={
1228
+ isFullRegion(region)
1229
+ ? "Download the section as a looping GIF"
1230
+ : "Download the section as a looping GIF — just the part you are looking at"
1231
+ }
1232
+ variant={onSaveGif ? "secondary" : "primary"}
1233
+ >
1234
+ {onSaveGif ? "Download" : "Download GIF"}
1235
+ </Button>
1236
+ </>
1237
+ )}
1238
+ </div>
1239
+ </section>
957
1240
  ) : null}
958
1241
 
959
1242
  {error ? <Alert tone="error">{error}</Alert> : null}
@@ -1037,3 +1320,17 @@ function marqueeStyle(rect: Region): React.CSSProperties {
1037
1320
  height: `${box.h * 100}%`,
1038
1321
  };
1039
1322
  }
1323
+
1324
+ /** Hand a made file to the browser's own download — the GIF of a host with no store. */
1325
+ function downloadBlob(blob: Blob, name: string): void {
1326
+ const url = URL.createObjectURL(blob);
1327
+ const link = document.createElement("a");
1328
+ link.href = url;
1329
+ link.download = name;
1330
+ link.rel = "noopener";
1331
+ document.body.append(link);
1332
+ link.click();
1333
+ link.remove();
1334
+ // After the click has been handed to the browser; revoking at once cancels it in Safari.
1335
+ setTimeout(() => URL.revokeObjectURL(url), 30_000);
1336
+ }
@@ -246,3 +246,34 @@
246
246
  max-inline-size: none;
247
247
  max-block-size: none;
248
248
  }
249
+
250
+ /*
251
+ * The GIF panel — a section of the video, as a looping GIF (owner, 2026-10-11). Two rows that
252
+ * wrap like the tools above them: WHAT (the section and its loop) and HOW (rate, width, make).
253
+ */
254
+ .inspector__gif {
255
+ display: flex;
256
+ flex-direction: column;
257
+ gap: 0.5rem;
258
+ padding: 0.625rem 0.75rem;
259
+ border: 1px solid var(--border);
260
+ border-radius: var(--radius-md, 0.5rem);
261
+ background: var(--surface-raised, transparent);
262
+ }
263
+
264
+ .inspector__gifrange,
265
+ .inspector__gifout {
266
+ display: flex;
267
+ align-items: center;
268
+ gap: 0.5rem;
269
+ flex-wrap: wrap;
270
+ }
271
+
272
+ .inspector__gifspan {
273
+ display: inline-flex;
274
+ align-items: baseline;
275
+ gap: 0.4rem;
276
+ padding-inline: 0.25rem;
277
+ font-variant-numeric: tabular-nums;
278
+ color: var(--text);
279
+ }
@@ -0,0 +1,90 @@
1
+ // ── A section of a video, as a looping GIF ───────────────────────────────────────────
2
+ // The capture half of the owner's 2026-10-11 ask (*"allow making a looping gif out of a
3
+ // section of a video"*): the section's rules and words, which the focus viewer's panel draws
4
+ // from. The capture is `gifRecord.ts` and the encoding `gifEncode.ts` — both loaded only when a
5
+ // GIF is made, so a gallery that never makes one carries neither.
6
+ // Not on the media entry's export list yet: a second player that wants them adds the line and
7
+ // its `publicSurface.baseline` entry together.
8
+
9
+ /** Longest section a GIF may be. Past this a GIF is a worse video: tens of MB, and slow to open. */
10
+ export const MAX_GIF_SECONDS = 15;
11
+ /** Shortest section worth looping — two frames at the lowest rate. */
12
+ export const MIN_GIF_SECONDS = 0.2;
13
+ /** What a new section spans from the playhead, before either end is moved. */
14
+ export const DEFAULT_GIF_SECONDS = 3;
15
+ /** The frame rates offered. A GIF's delay is in hundredths of a second, so 20 is near the top of what plays evenly. */
16
+ export const GIF_FRAME_RATES = [10, 15, 20] as const;
17
+ /** Output widths offered, in pixels — the height follows the picture's shape. */
18
+ export const GIF_WIDTHS = [320, 480, 640] as const;
19
+
20
+ export interface GifSection {
21
+ startSeconds: number;
22
+ endSeconds: number;
23
+ }
24
+
25
+ /**
26
+ * A section made sensible: inside the video, start before end, no shorter than
27
+ * {@link MIN_GIF_SECONDS} and no longer than {@link MAX_GIF_SECONDS}. When an edit would break
28
+ * a rule, the END that was not just moved gives way — `moved` says which end the hand is on,
29
+ * so marking a new start past the end drags the end along instead of refusing the start.
30
+ */
31
+ export function clampGifSection(section: GifSection, durationSeconds: number, moved: "start" | "end" = "end"): GifSection {
32
+ const duration = Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : Number.POSITIVE_INFINITY;
33
+ const span = (s: number) => Math.min(MAX_GIF_SECONDS, Math.max(MIN_GIF_SECONDS, s));
34
+ if (moved === "start") {
35
+ const start = Math.min(Math.max(0, section.startSeconds), Math.max(0, duration - MIN_GIF_SECONDS));
36
+ const length = span(section.endSeconds - start);
37
+ return { startSeconds: start, endSeconds: Math.min(duration, start + length) };
38
+ }
39
+ const end = Math.max(Math.min(duration, section.endSeconds), Math.min(duration, MIN_GIF_SECONDS));
40
+ const length = span(end - section.startSeconds);
41
+ return { startSeconds: Math.max(0, end - length), endSeconds: end };
42
+ }
43
+
44
+ /** The new section a "Make a GIF" press opens with: from the playhead, {@link DEFAULT_GIF_SECONDS} long. */
45
+ export function gifSectionAt(playheadSeconds: number, durationSeconds: number): GifSection {
46
+ return clampGifSection(
47
+ { startSeconds: playheadSeconds, endSeconds: playheadSeconds + DEFAULT_GIF_SECONDS },
48
+ durationSeconds,
49
+ "start",
50
+ );
51
+ }
52
+
53
+ /**
54
+ * The moments a section is sampled at: one every `1/fps` seconds from the start, the last
55
+ * strictly before the end — so a loop never shows the start frame twice in a row.
56
+ */
57
+ export function gifFrameTimes(section: GifSection, fps: number): number[] {
58
+ const length = section.endSeconds - section.startSeconds;
59
+ const count = Math.max(1, Math.round(length * fps));
60
+ return Array.from({ length: count }, (_, i) => section.startSeconds + i / fps);
61
+ }
62
+
63
+ /**
64
+ * The GIF's pixel size for a crop of `crop` source pixels at a requested `width` — never
65
+ * wider than the crop itself (upscaling a GIF only adds bytes) and never below 2×2.
66
+ */
67
+ export function gifSize(crop: { sw: number; sh: number }, width: number): { width: number; height: number } {
68
+ const w = Math.max(2, Math.round(Math.min(width, crop.sw)));
69
+ return { width: w, height: Math.max(2, Math.round((w * crop.sh) / crop.sw)) };
70
+ }
71
+
72
+ /** `0:12.4` — tenths, because a GIF section is set to the frame, not to the second. */
73
+ export function formatGifTime(seconds: number): string {
74
+ const safe = Math.max(0, Number.isFinite(seconds) ? seconds : 0);
75
+ const tenths = Math.round(safe * 10);
76
+ const m = Math.floor(tenths / 600);
77
+ const s = (tenths % 600) / 10;
78
+ return `${m}:${s.toFixed(1).padStart(4, "0")}`;
79
+ }
80
+
81
+ /** `2.4 MB` / `840 KB` — what a saved GIF weighs, said after it is made. */
82
+ export function formatGifBytes(bytes: number): string {
83
+ return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`;
84
+ }
85
+
86
+ /** A file name for a downloaded GIF: the video's name without its extension, and where it starts. */
87
+ export function gifFileName(title: string | undefined, startSeconds: number): string {
88
+ const base = (title ?? "").replace(/\.[^./\\]+$/, "").replace(/[\\/:*?"<>|]+/g, " ").trim() || "clip";
89
+ return `${base} @ ${formatGifTime(startSeconds).replace(":", "-")}.gif`;
90
+ }