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.
- package/dist/react/canvas-hud/CanvasToolbar.d.ts +1 -1
- package/dist/react/components/DataTable/TableFilterBuilder.d.ts.map +1 -1
- package/dist/react/components/DataTable/TableFilterBuilder.js +2 -2
- package/dist/react/components/DataTable/TableFilterBuilder.js.map +1 -1
- package/dist/react/components/ListToolbar.d.ts +2 -2
- package/dist/react/components/ListToolbar.d.ts.map +1 -1
- package/dist/react/components/ListToolbar.js +1 -1
- package/dist/react/hooks/useOutsidePress.d.ts.map +1 -1
- package/dist/react/hooks/useOutsidePress.js +18 -3
- package/dist/react/hooks/useOutsidePress.js.map +1 -1
- package/dist/react/hooks/useScrollMemory.d.ts +4 -3
- package/dist/react/hooks/useScrollMemory.d.ts.map +1 -1
- package/dist/react/hooks/useScrollMemory.js +16 -2
- package/dist/react/hooks/useScrollMemory.js.map +1 -1
- package/dist/react/media/FocusViewer.d.ts +8 -1
- package/dist/react/media/FocusViewer.d.ts.map +1 -1
- package/dist/react/media/FocusViewer.js +174 -10
- package/dist/react/media/FocusViewer.js.map +1 -1
- package/dist/react/media/focusViewer.css +31 -0
- package/dist/react/media/gifClip.d.ts +46 -0
- package/dist/react/media/gifClip.d.ts.map +1 -0
- package/dist/react/media/gifClip.js +74 -0
- package/dist/react/media/gifClip.js.map +1 -0
- package/dist/react/media/gifEncode.d.ts +46 -0
- package/dist/react/media/gifEncode.d.ts.map +1 -0
- package/dist/react/media/gifEncode.js +288 -0
- package/dist/react/media/gifEncode.js.map +1 -0
- package/dist/react/media/gifRecord.d.ts +26 -0
- package/dist/react/media/gifRecord.d.ts.map +1 -0
- package/dist/react/media/gifRecord.js +93 -0
- package/dist/react/media/gifRecord.js.map +1 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/analytics.css +9 -9
- package/dist/styles-areas/data-table.css +10 -10
- package/dist/styles-areas/media.css +2 -2
- package/package.json +3 -3
- package/scripts/guardrailsEnforce.spec.ts +10 -0
- package/scripts/guardrailsEnforce.ts +4 -1
- package/scripts/styleAreas.ts +3 -1
- package/src/react/canvas-hud/CanvasToolbar.tsx +1 -1
- package/src/react/components/DataTable/TableFilterBuilder.tsx +9 -13
- package/src/react/components/DataTable/tableSystem.spec.tsx +15 -7
- package/src/react/components/ListToolbar.tsx +2 -2
- package/src/react/hooks/useOutsidePress.spec.tsx +20 -0
- package/src/react/hooks/useOutsidePress.ts +17 -2
- package/src/react/hooks/useScrollMemory.spec.tsx +45 -2
- package/src/react/hooks/useScrollMemory.ts +20 -5
- package/src/react/media/FocusViewer.tsx +322 -25
- package/src/react/media/focusViewer.css +31 -0
- package/src/react/media/gifClip.ts +90 -0
- package/src/react/media/gifEncode.spec.ts +213 -0
- package/src/react/media/gifEncode.ts +298 -0
- package/src/react/media/gifRecord.ts +103 -0
- package/src/styles-areas/analytics.css +9 -9
- package/src/styles-areas/data-table.css +10 -10
- 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
|
|
155
|
-
*
|
|
156
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 === "
|
|
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
|
-
<
|
|
960
|
+
<SelectField
|
|
801
961
|
id="inspector-speed"
|
|
802
962
|
className="w-auto"
|
|
803
|
-
|
|
804
|
-
const next = Number(
|
|
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
|
-
<
|
|
1069
|
+
<SelectField
|
|
915
1070
|
id="inspector-upscale"
|
|
916
1071
|
className="w-auto"
|
|
917
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
+
}
|