@hugpy/video-intelligence-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +41 -0
- package/assets/sampels/images/bycycle/bd8603bad35d4f179f4d1134d8faf2ee.mp4 +0 -0
- package/assets/sampels/images/bycycle/frame_00000.png +0 -0
- package/assets/sampels/images/bycycle/frame_00001.png +0 -0
- package/assets/sampels/images/bycycle/frame_00002.png +0 -0
- package/assets/sampels/images/bycycle/frame_00003.png +0 -0
- package/assets/sampels/images/bycycle/frame_00004.png +0 -0
- package/assets/sampels/images/bycycle/frame_00005.png +0 -0
- package/assets/sampels/images/forrest/ff23090611b844a6b85c224f388bd542.mp4 +0 -0
- package/assets/sampels/images/forrest/frame_00000.png +0 -0
- package/assets/sampels/images/forrest/frame_00001.png +0 -0
- package/assets/sampels/images/forrest/frame_00002.png +0 -0
- package/assets/sampels/images/forrest/frame_00003.png +0 -0
- package/assets/sampels/images/forrest/frame_00004.png +0 -0
- package/assets/sampels/images/forrest/frame_00005.png +0 -0
- package/assets/sampels/images/shenzo/e1a4fcd623084ab4a986d11c8cd00db8.mp4 +0 -0
- package/assets/sampels/images/shenzo/frame_00000.png +0 -0
- package/assets/sampels/images/shenzo/frame_00001.png +0 -0
- package/assets/sampels/images/shenzo/frame_00002.png +0 -0
- package/assets/sampels/images/shenzo/frame_00003.png +0 -0
- package/assets/sampels/images/shenzo/frame_00004.png +0 -0
- package/assets/sampels/images/shenzo/frame_00005.png +0 -0
- package/assets/sampels/scenes/man.mp4 +0 -0
- package/assets/sampels/scenes/woman.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/_assembly/contrib_00.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/movie.json +67 -0
- package/assets/sampels/studio_movies/caverns/movie.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/manifest.json +87 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/provenance.json +7 -0
- package/assets/sampels/studio_movies/caverns/segment_01/branch.png +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/concat_list.txt +2 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_00.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_01.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/movie.json +81 -0
- package/assets/sampels/studio_movies/keeper-matrix/movie.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/manifest.json +87 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/provenance.json +7 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/branch.png +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/manifest.json +87 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/provenance.json +7 -0
- package/assets/sampels/videos/a_red_paper_boat_drifting_on_a_calm_pond__gentle_ripples__mo_a62d6c28.mp4 +0 -0
- package/assets/sampels/videos/aurora.mp4 +0 -0
- package/assets/sampels/videos/dark_night.mp4 +0 -0
- package/assets/sampels/videos/fire_and_soul.mp4 +0 -0
- package/assets/sampels/videos/harsh_seas.mp4 +0 -0
- package/entry.tsx +74 -0
- package/index.html +53 -0
- package/package.json +42 -0
- package/src/assets/hugpy-mark.png +0 -0
- package/src/config.ts +676 -0
- package/src/demo/DemoBanner.tsx +54 -0
- package/src/demo/assets/demo-audio.mp3 +0 -0
- package/src/demo/assets/demo-clip.mp4 +0 -0
- package/src/demo/assets/demo-crop.mp4 +0 -0
- package/src/demo/assets/demo-poster.jpg +0 -0
- package/src/demo/assets/frame-1.jpg +0 -0
- package/src/demo/assets/frame-2.jpg +0 -0
- package/src/demo/assets/frame-3.jpg +0 -0
- package/src/demo/assets/frame-4.jpg +0 -0
- package/src/demo/assets/frame-5.jpg +0 -0
- package/src/demo/assets/frame-6.jpg +0 -0
- package/src/demo/assets/gen-1.jpg +0 -0
- package/src/demo/assets/gen-2.jpg +0 -0
- package/src/demo/assets.d.ts +14 -0
- package/src/demo/demo.css +64 -0
- package/src/demo/demoFetch.ts +305 -0
- package/src/demo/fixtures.ts +814 -0
- package/src/demo/index.ts +43 -0
- package/src/demo/mode.ts +81 -0
- package/src/demo/promptAssist.ts +181 -0
- package/src/demo/seed.ts +97 -0
- package/src/nav/BrandMark.css +66 -0
- package/src/nav/BrandMark.tsx +40 -0
- package/src/nav/Navbar.css +13 -0
- package/src/nav/Navbar.tsx +68 -0
- package/src/regions/SpatialRegionEditor.tsx +375 -0
- package/src/regions/TemporalRegionEditor.tsx +485 -0
- package/src/regions/types.ts +85 -0
- package/src/session.ts +85 -0
- package/src/share.ts +77 -0
- package/src/stations/ActivePanel.tsx +55 -0
- package/src/stations/ActiveProcessesStation.tsx +272 -0
- package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +228 -0
- package/src/stations/AddFileBar.tsx +59 -0
- package/src/stations/AudioCropStation.tsx +157 -0
- package/src/stations/ComfyPanel.tsx +210 -0
- package/src/stations/ComfyStation.tsx +196 -0
- package/src/stations/CondensedKnobStrip.tsx +245 -0
- package/src/stations/ConsolePanel.tsx +82 -0
- package/src/stations/FrameExtractStation.tsx +148 -0
- package/src/stations/GenIdentityBar.tsx +412 -0
- package/src/stations/GenerateStation.tsx +5603 -0
- package/src/stations/GenerateStation.tsx.bak-tester-removal +5170 -0
- package/src/stations/GoalComposer.tsx +139 -0
- package/src/stations/ImageCropStation.tsx +153 -0
- package/src/stations/KnobInfo.tsx +64 -0
- package/src/stations/PlaceholderStation.tsx +26 -0
- package/src/stations/SectionTabs.tsx +276 -0
- package/src/stations/SharePanel.tsx +298 -0
- package/src/stations/SidebarRegistry.tsx +105 -0
- package/src/stations/StationShell.tsx +96 -0
- package/src/stations/StudioClipsStation.tsx +28 -0
- package/src/stations/WorkbenchDrawer.tsx +66 -0
- package/src/stations/WorkbenchSidebar.tsx +1224 -0
- package/src/stations/WorkbenchStation.tsx +91 -0
- package/src/stations/comfyEmbed.ts +79 -0
- package/src/stations/drawerShell.tsx +419 -0
- package/src/stations/ops/AudioCropCore.tsx +218 -0
- package/src/stations/ops/CharacterGroupsPanel.tsx +716 -0
- package/src/stations/ops/FrameExtractCore.tsx +695 -0
- package/src/stations/ops/ImageCropCore.tsx +164 -0
- package/src/stations/ops/VideoCropCore.tsx +193 -0
- package/src/stations/ops/useVideoCropJobs.ts +224 -0
- package/src/stations/pollCaps.ts +87 -0
- package/src/stations/registry.ts +191 -0
- package/src/stations/studio/AngleRingEditor.tsx +159 -0
- package/src/stations/studio/IdentitiesStation.tsx +2455 -0
- package/src/stations/studio/IdentityProfileControls.tsx +262 -0
- package/src/stations/studio/StudioActiveProcesses.tsx +224 -0
- package/src/stations/studio/StudioAssistLogPanel.tsx +220 -0
- package/src/stations/studio/StudioGenerateMode.tsx +29 -0
- package/src/stations/studio/StudioGenerateTab.tsx +2688 -0
- package/src/stations/studio/StudioItemCard.tsx +334 -0
- package/src/stations/studio/StudioLibraryTab.tsx +506 -0
- package/src/stations/studio/StudioMovieComposer.tsx +2875 -0
- package/src/stations/studio/StudioMovieComposer.tsx.bak-sessions-20260812 +2729 -0
- package/src/stations/studio/StudioMovieSessions.tsx +553 -0
- package/src/stations/studio/StudioMovieSessions.tsx.bak-sessions-20260812 +417 -0
- package/src/stations/studio/StudioPlane.tsx +302 -0
- package/src/stations/studio/StudioSplicedRow.tsx +476 -0
- package/src/stations/studio/StudioViewer.tsx +339 -0
- package/src/stations/studio/TurntableViewer.tsx +220 -0
- package/src/stations/studio/movieTimeline.ts +198 -0
- package/src/stations/studio/promptCard.tsx +498 -0
- package/src/stations/studio/studioItem.ts +87 -0
- package/src/stations/studio/studioShared.tsx +821 -0
- package/src/stations/studio/useIdentityProfiles.ts +575 -0
- package/src/stations/studio/useReconstruction.ts +283 -0
- package/src/stations/types.ts +43 -0
- package/src/stations/useAudioCropJobs.ts +202 -0
- package/src/stations/useAudioExtractJob.ts +144 -0
- package/src/stations/useCropJobs.ts +206 -0
- package/src/stations/useFrameJobs.ts +140 -0
- package/src/stations/useGenerateJob.ts +151 -0
- package/src/stations/useGenerateMovieJob.ts +177 -0
- package/src/stations/useGenerateRowJobs.ts +184 -0
- package/src/stations/useGenerateSceneJob.ts +156 -0
- package/src/stations/useGenerateSceneRowJobs.ts +282 -0
- package/src/style/app.css +4974 -0
- package/src/transport/client.ts +262 -0
- package/src/transport/telemetry.ts +97 -0
- package/src/video/DropReceptacle.tsx +122 -0
- package/src/video/PromptAssistButtons.tsx +118 -0
- package/src/video/composerBridge.ts +66 -0
- package/src/video/contract.ts +599 -0
- package/src/video/jobTracker.ts +702 -0
- package/src/video/mediaLibrary.ts +328 -0
- package/src/video/renderCompat.ts +512 -0
- package/src/video/sessionForm.ts +189 -0
- package/src/video/spreadAssist.ts +392 -0
- package/src/video/studioBridge.ts +82 -0
- package/src/video/useMediaJobs.ts +279 -0
- package/src/video/useModels.ts +413 -0
- package/src/video/useMoviePresets.ts +97 -0
- package/src/video/useMovieSessions.ts +292 -0
- package/src/video/usePresets.ts +167 -0
- package/src/video/useProjects.ts +87 -0
- package/src/video/usePromptAssist.ts +394 -0
- package/src/video/useRenderPresets.ts +221 -0
- package/src/video/useStudioAssistLog.ts +266 -0
- package/src/video/useStudioPresets.ts +128 -0
- package/src/video/useWorkerCalls.ts +165 -0
- package/tsconfig.json +17 -0
- package/vite.config.ts +33 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
// TemporalRegionEditor — a draggable time-window over an audio waveform, the
|
|
2
|
+
// temporal sibling of SpatialRegionEditor and the second concrete implementation
|
|
3
|
+
// of the RegionEditor contract (regions/types.ts). Used by the Audio Crop station
|
|
4
|
+
// in multiRegion mode: the user shapes a working window (drag the band to move it,
|
|
5
|
+
// edge handles to change start/end, preset buttons, numeric fields), scrubs/loops
|
|
6
|
+
// it with an <audio> element, adds it to a queue, and the queue is surfaced to the
|
|
7
|
+
// station via onChange — exactly like the spatial editor, only the axis differs.
|
|
8
|
+
//
|
|
9
|
+
// The working window lives in SECONDS at all times; we convert to display pixels
|
|
10
|
+
// only for painting/hit-testing, using the timeline scale renderedWidth/duration.
|
|
11
|
+
// Every emitted window is clamped to 0 ≤ start < end ≤ duration.
|
|
12
|
+
//
|
|
13
|
+
// Waveform: the raw audio bytes are fetched through request() (expect:
|
|
14
|
+
// "arraybuffer" — the transport's binary path) and decoded with Web Audio's
|
|
15
|
+
// AudioContext.decodeAudioData; channel-0 is downsampled to per-column peaks and
|
|
16
|
+
// painted to a <canvas>. Decode failure surfaces as a clean inline message and
|
|
17
|
+
// never throws (numeric editing + playback still work without the waveform).
|
|
18
|
+
//
|
|
19
|
+
// FUTURE WORK (stretch, intentionally skipped this phase): a spectrogram toggle
|
|
20
|
+
// (FFT per column → magnitude heatmap) over the same canvas. Not built — it must
|
|
21
|
+
// not threaten the build; the waveform is the shipping view.
|
|
22
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
23
|
+
import type {
|
|
24
|
+
RegionEditorProps,
|
|
25
|
+
TemporalPreset,
|
|
26
|
+
TemporalRegion,
|
|
27
|
+
} from "./types";
|
|
28
|
+
import type { MediaRef } from "../video/contract";
|
|
29
|
+
import { mediaBytesUrl } from "../config";
|
|
30
|
+
import { request, okValue, errorOf, describeAppError } from "../transport/client";
|
|
31
|
+
|
|
32
|
+
/** One-tap starting windows offered by default. */
|
|
33
|
+
export const DEFAULT_TEMPORAL_PRESETS: readonly TemporalPreset[] = [
|
|
34
|
+
{ label: "1s", seconds: 1 },
|
|
35
|
+
{ label: "5s", seconds: 5 },
|
|
36
|
+
{ label: "Full" }, // whole track — no fixed length
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
/** Shortest window we allow (seconds) — keeps start strictly below end. */
|
|
40
|
+
const MIN_WINDOW_S = 0.05;
|
|
41
|
+
/** Fixed peak resolution computed once per decode; sampled down to canvas width. */
|
|
42
|
+
const PEAK_BUCKETS = 1600;
|
|
43
|
+
/** Canvas drawing-buffer height (px). CSS height matches for crisp 1:1 painting. */
|
|
44
|
+
const CANVAS_H = 128;
|
|
45
|
+
|
|
46
|
+
interface Size {
|
|
47
|
+
w: number;
|
|
48
|
+
h: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface AudioDragState {
|
|
52
|
+
mode: "move" | "start" | "end";
|
|
53
|
+
startClientX: number;
|
|
54
|
+
startWindow: TemporalRegion;
|
|
55
|
+
/** seconds per display px, captured at drag start. */
|
|
56
|
+
secPerPx: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---- pure helpers -------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/** Clamp a window to a valid [0..dur] range with start + MIN_WINDOW_S ≤ end. */
|
|
62
|
+
function clampTemporal(r: TemporalRegion, dur: number): TemporalRegion {
|
|
63
|
+
const d = dur > 0 ? dur : 0;
|
|
64
|
+
let start = Math.max(0, Math.min(r.start_s, d));
|
|
65
|
+
let end = Math.max(0, Math.min(r.end_s, d));
|
|
66
|
+
if (end < start) {
|
|
67
|
+
// Tolerate an inverted numeric edit by swapping rather than rejecting.
|
|
68
|
+
const t = start;
|
|
69
|
+
start = end;
|
|
70
|
+
end = t;
|
|
71
|
+
}
|
|
72
|
+
if (end - start < MIN_WINDOW_S) {
|
|
73
|
+
end = Math.min(d, start + MIN_WINDOW_S);
|
|
74
|
+
if (end - start < MIN_WINDOW_S) start = Math.max(0, end - MIN_WINDOW_S);
|
|
75
|
+
}
|
|
76
|
+
return { start_s: start, end_s: end };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Downsample channel-0 to normalised [0..1] peaks (abs-max per bucket). */
|
|
80
|
+
function computePeaks(buf: AudioBuffer, buckets: number): Float32Array {
|
|
81
|
+
const data = buf.getChannelData(0);
|
|
82
|
+
const peaks = new Float32Array(buckets);
|
|
83
|
+
const block = Math.max(1, Math.floor(data.length / buckets));
|
|
84
|
+
for (let i = 0; i < buckets; i++) {
|
|
85
|
+
let max = 0;
|
|
86
|
+
const start = i * block;
|
|
87
|
+
const end = Math.min(data.length, start + block);
|
|
88
|
+
for (let j = start; j < end; j++) {
|
|
89
|
+
const v = Math.abs(data[j]);
|
|
90
|
+
if (v > max) max = v;
|
|
91
|
+
}
|
|
92
|
+
peaks[i] = max;
|
|
93
|
+
}
|
|
94
|
+
let peak = 0;
|
|
95
|
+
for (let i = 0; i < buckets; i++) if (peaks[i] > peak) peak = peaks[i];
|
|
96
|
+
if (peak > 0) for (let i = 0; i < buckets; i++) peaks[i] /= peak;
|
|
97
|
+
return peaks;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- rendered-size hook (mirrors SpatialRegionEditor) -------------------------
|
|
101
|
+
|
|
102
|
+
function useRenderedSize(ref: React.RefObject<HTMLElement>, dep: unknown): Size {
|
|
103
|
+
const [size, setSize] = useState<Size>({ w: 0, h: 0 });
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
const el = ref.current;
|
|
106
|
+
if (!el) return;
|
|
107
|
+
const measure = () => setSize({ w: el.clientWidth, h: el.clientHeight });
|
|
108
|
+
measure();
|
|
109
|
+
const ro = new ResizeObserver(measure);
|
|
110
|
+
ro.observe(el);
|
|
111
|
+
return () => ro.disconnect();
|
|
112
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
113
|
+
}, [dep]);
|
|
114
|
+
return size;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---- component ----------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
interface TemporalRegionEditorProps
|
|
120
|
+
extends RegionEditorProps<TemporalRegion, TemporalPreset> {
|
|
121
|
+
/** Source audio (kind==="audio"), or null before one is loaded — the editor
|
|
122
|
+
then renders its full chrome over a blank (flat) waveform. */
|
|
123
|
+
source: MediaRef | null;
|
|
124
|
+
/** Total duration in seconds — the timeline scale (0 when no source). */
|
|
125
|
+
duration: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function TemporalRegionEditor({
|
|
129
|
+
source,
|
|
130
|
+
duration,
|
|
131
|
+
values,
|
|
132
|
+
onChange,
|
|
133
|
+
presets = DEFAULT_TEMPORAL_PRESETS as TemporalPreset[],
|
|
134
|
+
numericInputs = true,
|
|
135
|
+
multiRegion = true,
|
|
136
|
+
}: TemporalRegionEditorProps) {
|
|
137
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
138
|
+
const audioRef = useRef<HTMLAudioElement>(null);
|
|
139
|
+
const rendered = useRenderedSize(canvasRef, source?.uri);
|
|
140
|
+
|
|
141
|
+
const [working, setWorking] = useState<TemporalRegion>(() =>
|
|
142
|
+
clampTemporal({ start_s: 0, end_s: Math.min(duration, 5) }, duration),
|
|
143
|
+
);
|
|
144
|
+
const workingRef = useRef<TemporalRegion>(working);
|
|
145
|
+
workingRef.current = working;
|
|
146
|
+
|
|
147
|
+
const [drag, setDrag] = useState<AudioDragState | null>(null);
|
|
148
|
+
const [peaks, setPeaks] = useState<Float32Array | null>(null);
|
|
149
|
+
const [decoding, setDecoding] = useState<boolean>(false);
|
|
150
|
+
const [decodeError, setDecodeError] = useState<string | null>(null);
|
|
151
|
+
|
|
152
|
+
const [playing, setPlaying] = useState<boolean>(false);
|
|
153
|
+
const [playhead, setPlayhead] = useState<number>(0);
|
|
154
|
+
|
|
155
|
+
const queue = values ?? [];
|
|
156
|
+
|
|
157
|
+
// display px per second — the timeline scale used for painting/hit-testing.
|
|
158
|
+
const dispScale = rendered.w > 0 && duration > 0 ? rendered.w / duration : 0;
|
|
159
|
+
|
|
160
|
+
const setClamped = useCallback(
|
|
161
|
+
(r: TemporalRegion) => setWorking(clampTemporal(r, duration)),
|
|
162
|
+
[duration],
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Reset the working window (and playback) when the source or duration swaps.
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
setWorking(clampTemporal({ start_s: 0, end_s: Math.min(duration, 5) }, duration));
|
|
168
|
+
setPlaying(false);
|
|
169
|
+
setPlayhead(0);
|
|
170
|
+
}, [source?.uri, duration]);
|
|
171
|
+
|
|
172
|
+
// ---- fetch bytes → decode → peaks (never throws) ----
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
// No source yet — leave the waveform flat (baseline only), no fetch.
|
|
175
|
+
if (!source) {
|
|
176
|
+
setDecoding(false);
|
|
177
|
+
setDecodeError(null);
|
|
178
|
+
setPeaks(null);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const uri = source.uri;
|
|
182
|
+
let cancelled = false;
|
|
183
|
+
const ctrl = new AbortController();
|
|
184
|
+
setDecoding(true);
|
|
185
|
+
setDecodeError(null);
|
|
186
|
+
setPeaks(null);
|
|
187
|
+
|
|
188
|
+
void (async () => {
|
|
189
|
+
const r = await request<ArrayBuffer>(mediaBytesUrl(uri), {
|
|
190
|
+
expect: "arraybuffer",
|
|
191
|
+
signal: ctrl.signal,
|
|
192
|
+
meta: { specKey: "audio-crop", operation: "waveform.fetch" },
|
|
193
|
+
});
|
|
194
|
+
if (cancelled) return;
|
|
195
|
+
if (!r.ok) {
|
|
196
|
+
setDecodeError(describeAppError(errorOf(r)));
|
|
197
|
+
setDecoding(false);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const Ctor =
|
|
201
|
+
window.AudioContext ??
|
|
202
|
+
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
|
203
|
+
.webkitAudioContext;
|
|
204
|
+
if (!Ctor) {
|
|
205
|
+
setDecodeError("Web Audio is unavailable — waveform preview disabled.");
|
|
206
|
+
setDecoding(false);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const ac = new Ctor();
|
|
210
|
+
try {
|
|
211
|
+
const audioBuf = await ac.decodeAudioData(okValue(r));
|
|
212
|
+
if (!cancelled) setPeaks(computePeaks(audioBuf, PEAK_BUCKETS));
|
|
213
|
+
} catch {
|
|
214
|
+
if (!cancelled) setDecodeError("Could not decode this audio for a waveform.");
|
|
215
|
+
} finally {
|
|
216
|
+
void ac.close();
|
|
217
|
+
if (!cancelled) setDecoding(false);
|
|
218
|
+
}
|
|
219
|
+
})();
|
|
220
|
+
|
|
221
|
+
return () => {
|
|
222
|
+
cancelled = true;
|
|
223
|
+
ctrl.abort();
|
|
224
|
+
};
|
|
225
|
+
}, [source?.uri]);
|
|
226
|
+
|
|
227
|
+
// ---- paint the waveform (+ baseline) to the canvas ----
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
const canvas = canvasRef.current;
|
|
230
|
+
if (!canvas) return;
|
|
231
|
+
const w = Math.max(1, Math.round(rendered.w));
|
|
232
|
+
const h = CANVAS_H;
|
|
233
|
+
if (canvas.width !== w) canvas.width = w;
|
|
234
|
+
if (canvas.height !== h) canvas.height = h;
|
|
235
|
+
const ctx = canvas.getContext("2d");
|
|
236
|
+
if (!ctx) return;
|
|
237
|
+
ctx.clearRect(0, 0, w, h);
|
|
238
|
+
const mid = h / 2;
|
|
239
|
+
// baseline
|
|
240
|
+
ctx.strokeStyle = "#26262a";
|
|
241
|
+
ctx.lineWidth = 1;
|
|
242
|
+
ctx.beginPath();
|
|
243
|
+
ctx.moveTo(0, mid + 0.5);
|
|
244
|
+
ctx.lineTo(w, mid + 0.5);
|
|
245
|
+
ctx.stroke();
|
|
246
|
+
if (!peaks || peaks.length === 0) return;
|
|
247
|
+
// waveform bars
|
|
248
|
+
ctx.strokeStyle = "#8ab4ff";
|
|
249
|
+
ctx.beginPath();
|
|
250
|
+
for (let x = 0; x < w; x++) {
|
|
251
|
+
const idx = Math.min(peaks.length - 1, Math.floor((x / w) * peaks.length));
|
|
252
|
+
const barH = Math.max(1, peaks[idx] * (h * 0.9));
|
|
253
|
+
ctx.moveTo(x + 0.5, mid - barH / 2);
|
|
254
|
+
ctx.lineTo(x + 0.5, mid + barH / 2);
|
|
255
|
+
}
|
|
256
|
+
ctx.stroke();
|
|
257
|
+
}, [peaks, rendered.w]);
|
|
258
|
+
|
|
259
|
+
// ---- drag lifecycle: window listeners while a pointer is down ----
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
if (!drag) return;
|
|
262
|
+
const onMove = (e: PointerEvent) => {
|
|
263
|
+
const dSec = (e.clientX - drag.startClientX) * drag.secPerPx;
|
|
264
|
+
if (drag.mode === "move") {
|
|
265
|
+
const len = drag.startWindow.end_s - drag.startWindow.start_s;
|
|
266
|
+
let start = drag.startWindow.start_s + dSec;
|
|
267
|
+
start = Math.max(0, Math.min(start, duration - len));
|
|
268
|
+
setWorking({ start_s: start, end_s: start + len });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (drag.mode === "start") {
|
|
272
|
+
let start = drag.startWindow.start_s + dSec;
|
|
273
|
+
start = Math.max(0, Math.min(start, drag.startWindow.end_s - MIN_WINDOW_S));
|
|
274
|
+
setWorking({ start_s: start, end_s: drag.startWindow.end_s });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
let end = drag.startWindow.end_s + dSec;
|
|
278
|
+
end = Math.min(duration, Math.max(end, drag.startWindow.start_s + MIN_WINDOW_S));
|
|
279
|
+
setWorking({ start_s: drag.startWindow.start_s, end_s: end });
|
|
280
|
+
};
|
|
281
|
+
const onUp = () => setDrag(null);
|
|
282
|
+
window.addEventListener("pointermove", onMove);
|
|
283
|
+
window.addEventListener("pointerup", onUp);
|
|
284
|
+
return () => {
|
|
285
|
+
window.removeEventListener("pointermove", onMove);
|
|
286
|
+
window.removeEventListener("pointerup", onUp);
|
|
287
|
+
};
|
|
288
|
+
}, [drag, duration]);
|
|
289
|
+
|
|
290
|
+
const startDrag = (mode: AudioDragState["mode"]) => (e: React.PointerEvent) => {
|
|
291
|
+
if (dispScale <= 0) return;
|
|
292
|
+
e.preventDefault();
|
|
293
|
+
if (mode !== "move") e.stopPropagation();
|
|
294
|
+
setDrag({
|
|
295
|
+
mode,
|
|
296
|
+
startClientX: e.clientX,
|
|
297
|
+
startWindow: working,
|
|
298
|
+
secPerPx: 1 / dispScale,
|
|
299
|
+
});
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// ---- region-loop playback (single <audio> element) ----
|
|
303
|
+
useEffect(() => {
|
|
304
|
+
if (!playing) return;
|
|
305
|
+
let raf = 0;
|
|
306
|
+
const loop = () => {
|
|
307
|
+
const a = audioRef.current;
|
|
308
|
+
if (a) {
|
|
309
|
+
const win = workingRef.current;
|
|
310
|
+
if (a.currentTime >= win.end_s) a.currentTime = win.start_s;
|
|
311
|
+
setPlayhead(a.currentTime);
|
|
312
|
+
}
|
|
313
|
+
raf = requestAnimationFrame(loop);
|
|
314
|
+
};
|
|
315
|
+
raf = requestAnimationFrame(loop);
|
|
316
|
+
return () => cancelAnimationFrame(raf);
|
|
317
|
+
}, [playing]);
|
|
318
|
+
|
|
319
|
+
const togglePlay = () => {
|
|
320
|
+
const a = audioRef.current;
|
|
321
|
+
if (!a) return;
|
|
322
|
+
if (playing) {
|
|
323
|
+
a.pause();
|
|
324
|
+
setPlaying(false);
|
|
325
|
+
setPlayhead(working.start_s);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
a.currentTime = working.start_s;
|
|
330
|
+
} catch {
|
|
331
|
+
/* currentTime may not be settable until metadata loads — play anyway */
|
|
332
|
+
}
|
|
333
|
+
void a
|
|
334
|
+
.play()
|
|
335
|
+
.then(() => setPlaying(true))
|
|
336
|
+
.catch(() => setPlaying(false));
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
// ---- numeric field editing (seconds) ----
|
|
340
|
+
const setField =
|
|
341
|
+
(key: keyof TemporalRegion) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
342
|
+
const raw = Number(e.target.value);
|
|
343
|
+
const v = Number.isFinite(raw) ? raw : 0;
|
|
344
|
+
setClamped({ ...working, [key]: v });
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// ---- queue mutations (surface via onChange) ----
|
|
348
|
+
const addToQueue = () => {
|
|
349
|
+
const region = clampTemporal(working, duration);
|
|
350
|
+
onChange(multiRegion ? [...queue, region] : region);
|
|
351
|
+
};
|
|
352
|
+
const removeAt = (i: number) => onChange(queue.filter((_, idx) => idx !== i));
|
|
353
|
+
const applyPreset = (p: TemporalPreset) => {
|
|
354
|
+
if (p.seconds == null) {
|
|
355
|
+
setClamped({ start_s: 0, end_s: duration });
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
setClamped({ start_s: working.start_s, end_s: working.start_s + p.seconds });
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
// display geometry for the region band + playhead
|
|
362
|
+
const regionStyle = useMemo(
|
|
363
|
+
() => ({
|
|
364
|
+
left: `${working.start_s * dispScale}px`,
|
|
365
|
+
width: `${(working.end_s - working.start_s) * dispScale}px`,
|
|
366
|
+
}),
|
|
367
|
+
[working, dispScale],
|
|
368
|
+
);
|
|
369
|
+
const playheadStyle = useMemo(
|
|
370
|
+
() => ({ left: `${playhead * dispScale}px` }),
|
|
371
|
+
[playhead, dispScale],
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const windowLen = Math.max(0, working.end_s - working.start_s);
|
|
375
|
+
|
|
376
|
+
return (
|
|
377
|
+
<div className="vi-crop-editor">
|
|
378
|
+
<div className="vi-audio-stage">
|
|
379
|
+
<canvas ref={canvasRef} className="vi-audio-canvas" />
|
|
380
|
+
<div className="vi-audio-overlay">
|
|
381
|
+
<div
|
|
382
|
+
className={
|
|
383
|
+
drag?.mode === "move" ? "vi-audio-region vi-audio-region-active" : "vi-audio-region"
|
|
384
|
+
}
|
|
385
|
+
style={regionStyle}
|
|
386
|
+
onPointerDown={startDrag("move")}
|
|
387
|
+
>
|
|
388
|
+
<span
|
|
389
|
+
className="vi-audio-handle vi-audio-handle-start"
|
|
390
|
+
onPointerDown={startDrag("start")}
|
|
391
|
+
/>
|
|
392
|
+
<span
|
|
393
|
+
className="vi-audio-handle vi-audio-handle-end"
|
|
394
|
+
onPointerDown={startDrag("end")}
|
|
395
|
+
/>
|
|
396
|
+
</div>
|
|
397
|
+
{playing && <div className="vi-audio-playhead" style={playheadStyle} />}
|
|
398
|
+
</div>
|
|
399
|
+
{decoding && <span className="vi-audio-note">Decoding waveform…</span>}
|
|
400
|
+
{decodeError && !decoding && (
|
|
401
|
+
<span className="vi-audio-note vi-audio-note-warn">{decodeError}</span>
|
|
402
|
+
)}
|
|
403
|
+
</div>
|
|
404
|
+
|
|
405
|
+
<div className="vi-crop-controls">
|
|
406
|
+
<div className="vi-crop-presets">
|
|
407
|
+
{presets.map((p) => (
|
|
408
|
+
<button
|
|
409
|
+
key={p.label}
|
|
410
|
+
type="button"
|
|
411
|
+
className="vi-btn vi-btn-ghost"
|
|
412
|
+
onClick={() => applyPreset(p)}
|
|
413
|
+
>
|
|
414
|
+
{p.label}
|
|
415
|
+
</button>
|
|
416
|
+
))}
|
|
417
|
+
<button
|
|
418
|
+
type="button"
|
|
419
|
+
className={playing ? "vi-btn vi-btn-accent" : "vi-btn"}
|
|
420
|
+
onClick={togglePlay}
|
|
421
|
+
>
|
|
422
|
+
{playing ? "■ Stop" : "▶ Play region"}
|
|
423
|
+
</button>
|
|
424
|
+
</div>
|
|
425
|
+
|
|
426
|
+
{numericInputs && (
|
|
427
|
+
<div className="vi-crop-fields">
|
|
428
|
+
{(["start_s", "end_s"] as const).map((k) => (
|
|
429
|
+
<label key={k} className="vi-crop-field">
|
|
430
|
+
<span>{k}</span>
|
|
431
|
+
<input
|
|
432
|
+
type="number"
|
|
433
|
+
min={0}
|
|
434
|
+
max={duration}
|
|
435
|
+
step="any"
|
|
436
|
+
value={working[k]}
|
|
437
|
+
onChange={setField(k)}
|
|
438
|
+
/>
|
|
439
|
+
</label>
|
|
440
|
+
))}
|
|
441
|
+
<span className="vi-crop-native">
|
|
442
|
+
{windowLen.toFixed(2)}s · of {duration.toFixed(2)}s
|
|
443
|
+
</span>
|
|
444
|
+
</div>
|
|
445
|
+
)}
|
|
446
|
+
|
|
447
|
+
<div className="vi-crop-actions">
|
|
448
|
+
<button type="button" className="vi-btn vi-btn-accent" onClick={addToQueue}>
|
|
449
|
+
{multiRegion ? "Add clip to queue" : "Set clip"}
|
|
450
|
+
</button>
|
|
451
|
+
</div>
|
|
452
|
+
|
|
453
|
+
{multiRegion && queue.length > 0 && (
|
|
454
|
+
<ul className="vi-region-list">
|
|
455
|
+
{queue.map((r, i) => (
|
|
456
|
+
<li key={i} className="vi-region-item">
|
|
457
|
+
<span className="vi-region-idx">#{i + 1}</span>
|
|
458
|
+
<code className="vi-region-dims">
|
|
459
|
+
{r.start_s.toFixed(2)}–{r.end_s.toFixed(2)}s
|
|
460
|
+
</code>
|
|
461
|
+
<button
|
|
462
|
+
type="button"
|
|
463
|
+
className="vi-btn vi-btn-ghost vi-btn-sm"
|
|
464
|
+
onClick={() => removeAt(i)}
|
|
465
|
+
>
|
|
466
|
+
remove
|
|
467
|
+
</button>
|
|
468
|
+
</li>
|
|
469
|
+
))}
|
|
470
|
+
</ul>
|
|
471
|
+
)}
|
|
472
|
+
</div>
|
|
473
|
+
|
|
474
|
+
{/* Scrub + region-loop playback source. Direct media URL (like <img> in the
|
|
475
|
+
spatial editor) — not a fetch; playback works without HTTP Range. */}
|
|
476
|
+
<audio
|
|
477
|
+
ref={audioRef}
|
|
478
|
+
src={source ? mediaBytesUrl(source.uri) : undefined}
|
|
479
|
+
preload="metadata"
|
|
480
|
+
onPause={() => setPlaying(false)}
|
|
481
|
+
onEnded={() => setPlaying(false)}
|
|
482
|
+
/>
|
|
483
|
+
</div>
|
|
484
|
+
);
|
|
485
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The RegionEditor contract (architecture map §7) — the shared abstraction every
|
|
2
|
+
// region editor in the arm implements. A region editor lets the user pick a slice
|
|
3
|
+
// of a MediaRef along ONE axis: spatial (a bbox, this phase's SpatialRegionEditor)
|
|
4
|
+
// or temporal (a time window, Phase 5's TemporalRegionEditor). Both editors take
|
|
5
|
+
// the same generic props shape and differ only in TRegion + how they paint, so the
|
|
6
|
+
// station wiring (queue a region, surface it via onChange) is identical for both.
|
|
7
|
+
|
|
8
|
+
/** A spatial bounding box in the source image's NATIVE pixel space (integers). */
|
|
9
|
+
export interface SpatialRegion {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
w: number;
|
|
13
|
+
h: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A temporal window, in seconds — the axis the Phase 5 audio-crop station edits. */
|
|
17
|
+
export interface TemporalRegion {
|
|
18
|
+
start_s: number;
|
|
19
|
+
end_s: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Any region an editor manipulates. Widen the union as new axes land. */
|
|
23
|
+
export type Region = SpatialRegion | TemporalRegion;
|
|
24
|
+
|
|
25
|
+
/** Common shape of a one-tap starting-region button. */
|
|
26
|
+
export interface RegionPreset {
|
|
27
|
+
/** Button label, e.g. "1:1", "16:9", "1024²". */
|
|
28
|
+
label: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A spatial preset: a labelled aspect ratio (width / height) or a fixed native
|
|
33
|
+
* size. Applying one seeds the working bbox — the largest centered box of that
|
|
34
|
+
* aspect that fits the source, or a box of the fixed `size` clamped to it.
|
|
35
|
+
*/
|
|
36
|
+
export interface SpatialPreset extends RegionPreset {
|
|
37
|
+
/**
|
|
38
|
+
* Aspect ratio as width / height (e.g. 1 for 1:1, 16 / 9 for 16:9). `null`
|
|
39
|
+
* means "native" — take the source frame's own aspect (the whole image).
|
|
40
|
+
*/
|
|
41
|
+
aspect: number | null;
|
|
42
|
+
/**
|
|
43
|
+
* Optional fixed native pixel size, for model-native inputs like 1024². When
|
|
44
|
+
* present it wins over `aspect` for the seeded box (still clamped to the source).
|
|
45
|
+
*/
|
|
46
|
+
size?: { w: number; h: number };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A temporal preset: a one-tap starting window. `seconds` is a fixed window
|
|
51
|
+
* length seeded from the current start (e.g. "1s", "5s"); omit it for "Full" (the
|
|
52
|
+
* whole track). Mirrors SpatialPreset's role on the temporal axis — kept minimal.
|
|
53
|
+
*/
|
|
54
|
+
export interface TemporalPreset extends RegionPreset {
|
|
55
|
+
/** Fixed window length in seconds; omit for the whole-track "Full" preset. */
|
|
56
|
+
seconds?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Generic RegionEditor props. A single-region editor binds `value` + `onChange`;
|
|
61
|
+
* a `multiRegion` editor binds `values` (the queue) + `onChange` with the updated
|
|
62
|
+
* queue. onChange carries `TRegion` in single mode and `TRegion[]` in multi mode —
|
|
63
|
+
* one prop so a station wires exactly one handler regardless of mode.
|
|
64
|
+
*
|
|
65
|
+
* Parameterised by the region shape AND the preset shape so the future
|
|
66
|
+
* TemporalRegionEditor can slot in with its own preset type without touching this
|
|
67
|
+
* contract.
|
|
68
|
+
*/
|
|
69
|
+
export interface RegionEditorProps<
|
|
70
|
+
TRegion extends Region,
|
|
71
|
+
TPreset extends RegionPreset = RegionPreset,
|
|
72
|
+
> {
|
|
73
|
+
/** Current working region (single-region mode). */
|
|
74
|
+
value?: TRegion | null;
|
|
75
|
+
/** Queued regions (multi-region mode). */
|
|
76
|
+
values?: TRegion[];
|
|
77
|
+
/** Change surface: the working region (single) or the whole queue (multi). */
|
|
78
|
+
onChange: (next: TRegion | TRegion[]) => void;
|
|
79
|
+
/** One-tap starting-region buttons. */
|
|
80
|
+
presets?: TPreset[];
|
|
81
|
+
/** Render numeric field inputs bound two-way to the working region. */
|
|
82
|
+
numericInputs?: boolean;
|
|
83
|
+
/** Enable the add-to-queue affordance and render the queued list. */
|
|
84
|
+
multiRegion?: boolean;
|
|
85
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Per-tab session identity + connection metering for per-session server uploads.
|
|
2
|
+
//
|
|
3
|
+
// Clone of the media arm's session.ts with a video-scoped storage key. The
|
|
4
|
+
// browser owns a random session id in sessionStorage; uploads carry the sid as
|
|
5
|
+
// a form field; while the page is open we heartbeat /session/ping so the server
|
|
6
|
+
// keeps that session's uploads alive; on tab close we beacon /session/end to
|
|
7
|
+
// wipe them immediately. A server-side 1h sweep is the safety net for missed
|
|
8
|
+
// beacons. Disabled entirely in canned demo mode (no backend is contacted, and
|
|
9
|
+
// sendBeacon bypasses the demo fetch shim).
|
|
10
|
+
import { hugpyConfig } from "./config";
|
|
11
|
+
import { isCanned } from "./demo/mode";
|
|
12
|
+
|
|
13
|
+
const SID_KEY = "hugpy.video.sid";
|
|
14
|
+
const HEARTBEAT_MS = 60_000;
|
|
15
|
+
|
|
16
|
+
function makeSid(): string {
|
|
17
|
+
try {
|
|
18
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID();
|
|
19
|
+
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
|
20
|
+
const a = new Uint8Array(16);
|
|
21
|
+
crypto.getRandomValues(a);
|
|
22
|
+
return Array.from(a, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
/* fall through to the non-crypto fallback */
|
|
26
|
+
}
|
|
27
|
+
return `s${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Stable per-tab session id (sessionStorage). Created on first use. */
|
|
31
|
+
export function getSessionId(): string {
|
|
32
|
+
try {
|
|
33
|
+
let sid = sessionStorage.getItem(SID_KEY);
|
|
34
|
+
if (!sid) {
|
|
35
|
+
sid = makeSid();
|
|
36
|
+
sessionStorage.setItem(SID_KEY, sid);
|
|
37
|
+
}
|
|
38
|
+
return sid;
|
|
39
|
+
} catch {
|
|
40
|
+
// storage unavailable — a stable-enough per-load id still tags the upload.
|
|
41
|
+
return makeSid();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let started = false;
|
|
46
|
+
|
|
47
|
+
/** Start heartbeat + tab-close beacon so the server can wipe uploads per session. */
|
|
48
|
+
export function startSessionMetering(): void {
|
|
49
|
+
if (started || typeof window === "undefined") return;
|
|
50
|
+
if (isCanned()) return;
|
|
51
|
+
started = true;
|
|
52
|
+
|
|
53
|
+
const sid = getSessionId();
|
|
54
|
+
const base = hugpyConfig.apiBase.replace(/\/$/, "");
|
|
55
|
+
const q = `sid=${encodeURIComponent(sid)}`;
|
|
56
|
+
|
|
57
|
+
// Heartbeat — a "simple" POST (query param, no custom header / JSON) so it never
|
|
58
|
+
// triggers a CORS preflight in cross-origin deploys. Failures are ignored.
|
|
59
|
+
const ping = () => {
|
|
60
|
+
try {
|
|
61
|
+
void fetch(`${base}/session/ping?${q}`, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
credentials: hugpyConfig.withCredentials ? "include" : "same-origin",
|
|
64
|
+
keepalive: true,
|
|
65
|
+
}).catch(() => {});
|
|
66
|
+
} catch {
|
|
67
|
+
/* ignore */
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
ping(); // announce on load
|
|
72
|
+
window.setInterval(ping, HEARTBEAT_MS);
|
|
73
|
+
|
|
74
|
+
// Tab close → wipe now (best-effort). Skip bfcache navigations (persisted),
|
|
75
|
+
// which may be restored; the heartbeat resumes if so, and the 1h sweep is the
|
|
76
|
+
// backstop either way.
|
|
77
|
+
window.addEventListener("pagehide", (e) => {
|
|
78
|
+
if ((e as PageTransitionEvent).persisted) return;
|
|
79
|
+
try {
|
|
80
|
+
navigator.sendBeacon?.(`${base}/session/end?${q}`);
|
|
81
|
+
} catch {
|
|
82
|
+
/* ignore */
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|