@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,702 @@
|
|
|
1
|
+
// The session-wide job tracker — a durable, cross-station memory of every job
|
|
2
|
+
// this session has enqueued on the hugpy bus.
|
|
3
|
+
//
|
|
4
|
+
// The problem it solves: WorkbenchStation mounts ONLY the active sub-tab, so
|
|
5
|
+
// switching tabs UNMOUNTS a station and destroys its per-hook polling loop +
|
|
6
|
+
// JobCard state. The server keeps working (the job bus is durable: GET
|
|
7
|
+
// /video/jobs/<id> answers from any worker), but the UI forgot it was watching,
|
|
8
|
+
// so outputs never reached the library and the tab looked idle on return.
|
|
9
|
+
//
|
|
10
|
+
// This module lifts the polling out of the (unmountable) hooks into ONE
|
|
11
|
+
// module-level loop that lives for the whole session. Records persist to
|
|
12
|
+
// sessionStorage (write-through, keyed by session id) so in-flight jobs survive
|
|
13
|
+
// BOTH sub-tab switches AND page reloads — on reload we rehydrate and resume
|
|
14
|
+
// polling every non-terminal job. The five hooks now just POST their enqueue and
|
|
15
|
+
// register the returned job_id here; everything downstream (poll cadence, caps,
|
|
16
|
+
// terminal parse, the ONE centralized library push) happens in this module.
|
|
17
|
+
//
|
|
18
|
+
// Architecture mirrors mediaLibrary.ts exactly: a module-level `cache` (the
|
|
19
|
+
// stable snapshot source), a `listeners` Set + `emit()`, a one-time storage
|
|
20
|
+
// bind, sessionStorage write-through on every mutation, `getSessionId()` reuse,
|
|
21
|
+
// and a `useSyncExternalStore` hook. getSnapshot returns the WHOLE list (a
|
|
22
|
+
// referentially-stable array) — consumers filter with useMemo in their own body,
|
|
23
|
+
// never inside getSnapshot (a fresh array per read loops React 18).
|
|
24
|
+
import { useSyncExternalStore } from "react";
|
|
25
|
+
import { getSessionId } from "../session";
|
|
26
|
+
import { isCanned } from "../demo/mode";
|
|
27
|
+
import { installDemoTransport } from "../demo/demoFetch";
|
|
28
|
+
import { jobStatusUrl, jobCancelUrl } from "../config";
|
|
29
|
+
import { request, okValue } from "../transport/client";
|
|
30
|
+
import { addToLibrary } from "./mediaLibrary";
|
|
31
|
+
import {
|
|
32
|
+
jobRecordSchema,
|
|
33
|
+
isTerminal,
|
|
34
|
+
isMovieProgress,
|
|
35
|
+
type JobStatus,
|
|
36
|
+
type MediaRef,
|
|
37
|
+
type JobProject,
|
|
38
|
+
type AnyProgress,
|
|
39
|
+
} from "./contract";
|
|
40
|
+
import type { SpatialRegion, TemporalRegion } from "../regions/types";
|
|
41
|
+
import {
|
|
42
|
+
GENERATE_POLL_CAP_MS,
|
|
43
|
+
JOB_POLL_CAP_MS,
|
|
44
|
+
generateScenePollCapMs,
|
|
45
|
+
generateMoviePollCapMs,
|
|
46
|
+
} from "../stations/pollCaps";
|
|
47
|
+
|
|
48
|
+
/** The job kinds this arm enqueues, one poll-parse branch per kind. */
|
|
49
|
+
export type JobKind =
|
|
50
|
+
| "crop"
|
|
51
|
+
| "frame_extract"
|
|
52
|
+
| "audio_extract"
|
|
53
|
+
| "generate_image"
|
|
54
|
+
| "generate_scene"
|
|
55
|
+
| "generate_movie";
|
|
56
|
+
|
|
57
|
+
/** The station a job belongs to — also the library origin for crop pushes.
|
|
58
|
+
* "video-crop" (Studio slice 2b) is the dedicated id for the one-pass spatial+
|
|
59
|
+
* temporal VIDEO crop (useVideoCropJobs), split out of "image-crop" so a video
|
|
60
|
+
* item's inline Video Crop records never intermix with Image Crop's tracker rows.
|
|
61
|
+
* "generate-tester" is the same split for the SAME reason: useGenerateJob picks
|
|
62
|
+
* its job as the newest record with station "generate", so the tester's N
|
|
63
|
+
* per-row jobs must not be filed there or the last row to enqueue would
|
|
64
|
+
* masquerade as the single-prompt run's result. */
|
|
65
|
+
export type StationId =
|
|
66
|
+
| "image-crop"
|
|
67
|
+
| "video-crop"
|
|
68
|
+
| "audio-crop"
|
|
69
|
+
| "frames"
|
|
70
|
+
| "generate"
|
|
71
|
+
| "generate-tester"
|
|
72
|
+
| "generate-scene-parts";
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One tracked job. Persisted to sessionStorage, so — because a record may be
|
|
76
|
+
* polled to completion AFTER a page reload (the enqueue closure is long gone) —
|
|
77
|
+
* the terminal library push is DATA-DRIVEN off this record, never a stored
|
|
78
|
+
* closure. `libLabel`/`kind`/`station` carry everything needed to reconstruct
|
|
79
|
+
* the exact library origin + label per kind (see pushOutputs).
|
|
80
|
+
*/
|
|
81
|
+
export interface TrackedJob {
|
|
82
|
+
/** Server job id — the durable handle GET /video/jobs/<id> answers to. */
|
|
83
|
+
jobId: string;
|
|
84
|
+
/**
|
|
85
|
+
* Stable per-row correlation key for multi-job fan-out stations (the scene
|
|
86
|
+
* composer's part key). Lets a surface rebuild its row->job map from the durable
|
|
87
|
+
* tracker after an unmount, instead of a transient in-component useState. Null
|
|
88
|
+
* for single-job stations.
|
|
89
|
+
*/
|
|
90
|
+
rowKey: string | null;
|
|
91
|
+
kind: JobKind;
|
|
92
|
+
station: StationId;
|
|
93
|
+
/** Human process-row label (shown in the sidebar Processes section). */
|
|
94
|
+
label: string;
|
|
95
|
+
/** When the user kicked the run off (ms epoch) — the elapsed-clock anchor. */
|
|
96
|
+
enqueuedAt: number;
|
|
97
|
+
/** Poll-clock anchor for the cap; resume() (and reload) re-anchor it. */
|
|
98
|
+
startedAt: number;
|
|
99
|
+
/** Latest polled status (null = enqueued-but-not-yet-claimed, or forgotten). */
|
|
100
|
+
status: JobStatus;
|
|
101
|
+
/**
|
|
102
|
+
* Poll cap reached while still non-terminal (Phase 8). NOT a failure — the job
|
|
103
|
+
* may still finish server-side; polling THIS record is paused until resume()
|
|
104
|
+
* re-anchors it. Status is left untouched.
|
|
105
|
+
*/
|
|
106
|
+
capped: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* The server no longer knows this job id (a poll returned ok with status:null,
|
|
109
|
+
* e.g. after a reload of a job the bus already forgot). A non-crashing,
|
|
110
|
+
* terminal-ish state — polling stops; the row surfaces as "unknown / expired".
|
|
111
|
+
*/
|
|
112
|
+
expired: boolean;
|
|
113
|
+
/** Human message on failure (enqueue error or job error). */
|
|
114
|
+
error: string | null;
|
|
115
|
+
/** Produced MediaRefs, recorded on terminal `done`. */
|
|
116
|
+
outputs: MediaRef[];
|
|
117
|
+
/**
|
|
118
|
+
* The library label to push on `done` for crop kinds (carried from the enqueue
|
|
119
|
+
* site — `crop W×H` / `clip a–bs`). Null for kinds with a static or per-output
|
|
120
|
+
* label (generate/frames) or no push (audio_extract).
|
|
121
|
+
*/
|
|
122
|
+
libLabel: string | null;
|
|
123
|
+
/** 1-based display index for multi-job stations (crop / audio-crop). */
|
|
124
|
+
index: number;
|
|
125
|
+
/** The region a crop job crops — kept so cards re-render after a reload. */
|
|
126
|
+
region: SpatialRegion | TemporalRegion | null;
|
|
127
|
+
/**
|
|
128
|
+
* Requested frame count for a generate_scene job — the poll cap scales off it
|
|
129
|
+
* (see capFor). Null for every other kind. Persisted so the scaled cap survives
|
|
130
|
+
* a reload of an in-flight scene job.
|
|
131
|
+
*/
|
|
132
|
+
nFrames: number | null;
|
|
133
|
+
/**
|
|
134
|
+
* Whether a generate_scene job chains each frame on the previous (sequential
|
|
135
|
+
* img2img). Chaining is slower, so capFor adds extra per-frame poll time when
|
|
136
|
+
* true. Null for every other kind. Persisted so the scaled cap survives a reload
|
|
137
|
+
* of an in-flight scene job.
|
|
138
|
+
*/
|
|
139
|
+
chain: boolean | null;
|
|
140
|
+
/**
|
|
141
|
+
* Segment count (goal-timeline length) for a generate_movie job — the poll cap
|
|
142
|
+
* scales off it × the total frame count (see capFor). Null for every other kind.
|
|
143
|
+
* Persisted so the scaled cap survives a reload of an in-flight movie job.
|
|
144
|
+
*/
|
|
145
|
+
segments: number | null;
|
|
146
|
+
/**
|
|
147
|
+
* The prompt summary + model that produced a generate_image / generate_scene /
|
|
148
|
+
* generate_movie job — carried from the enqueue site so the ONE centralized
|
|
149
|
+
* library push (pushOutputs) can annotate each output with its provenance
|
|
150
|
+
* (session-library grouping + Replicate/Continue re-staging). Null for every
|
|
151
|
+
* other kind.
|
|
152
|
+
*/
|
|
153
|
+
prompt: string | null;
|
|
154
|
+
model: string | null;
|
|
155
|
+
/**
|
|
156
|
+
* Live per-run progress from the latest non-terminal poll. A UNION of the flat
|
|
157
|
+
* JobProgress (image/scene) and the nested MovieProgress (movie) — see
|
|
158
|
+
* AnyProgress. Null when the job hasn't reported progress yet, and cleared to
|
|
159
|
+
* null on any terminal transition so the running readout never lingers past the
|
|
160
|
+
* result. (Persisted, so pushOutputs can read the last movie progress to derive
|
|
161
|
+
* per-segment library labels — reload-safe.)
|
|
162
|
+
*/
|
|
163
|
+
progress: AnyProgress | null;
|
|
164
|
+
/**
|
|
165
|
+
* The auto-archive project a terminal generation was saved into (name + on-disk
|
|
166
|
+
* dir + uuid), captured from result.project on `done`. Null for non-generation
|
|
167
|
+
* kinds and while running. Persisted so the "Saved to …" line survives a reload.
|
|
168
|
+
*/
|
|
169
|
+
project: JobProject | null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The single sessionStorage key prefix (build marker — must reach the bundle). */
|
|
173
|
+
const KEY_PREFIX = "vi.jobTracker.v1";
|
|
174
|
+
|
|
175
|
+
/** Module poll cadence — matches the retired per-hook POLL_MS exactly. */
|
|
176
|
+
const POLL_MS = 800;
|
|
177
|
+
|
|
178
|
+
// Session-scoped key so two tabs keep independent trackers, and the persisted
|
|
179
|
+
// list travels with the same session id the uploads/jobs are tagged by.
|
|
180
|
+
//
|
|
181
|
+
// The canned demo (?demo=1) gets a `:demo`-SUFFIXED key so the brochure is
|
|
182
|
+
// hermetic: it never reads or writes the SAME tab's live-session tracker. Without
|
|
183
|
+
// this, a job persisted by a PRIOR live visit in this tab session would rehydrate
|
|
184
|
+
// under the demo (the sid is shared), its GET /video/jobs/<id> poll would miss in
|
|
185
|
+
// the shim, and it would surface as a stale "server no longer tracks this job"
|
|
186
|
+
// expired row. isCanned() is URL-derived and available at module load, so the key
|
|
187
|
+
// is right from the first read regardless of when installVideoDemo() runs.
|
|
188
|
+
function storageKey(): string {
|
|
189
|
+
const suffix = isCanned() ? ":demo" : "";
|
|
190
|
+
try {
|
|
191
|
+
return `${KEY_PREFIX}:${getSessionId()}${suffix}`;
|
|
192
|
+
} catch {
|
|
193
|
+
return `${KEY_PREFIX}${suffix}`;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// In-memory cache is the source of truth for getSnapshot — its reference changes
|
|
198
|
+
// only on an actual mutation, which keeps useSyncExternalStore stable.
|
|
199
|
+
let cache: TrackedJob[] | null = null;
|
|
200
|
+
const listeners = new Set<() => void>();
|
|
201
|
+
let storageBound = false;
|
|
202
|
+
let pollTimer: number | null = null;
|
|
203
|
+
|
|
204
|
+
function readStorage(): TrackedJob[] {
|
|
205
|
+
try {
|
|
206
|
+
const raw = sessionStorage.getItem(storageKey());
|
|
207
|
+
if (!raw) return [];
|
|
208
|
+
const parsed = JSON.parse(raw);
|
|
209
|
+
if (!Array.isArray(parsed)) return [];
|
|
210
|
+
// Drop malformed entries defensively — a stored record must carry a jobId.
|
|
211
|
+
return parsed.filter(
|
|
212
|
+
(it) => it && typeof it === "object" && typeof it.jobId === "string",
|
|
213
|
+
) as TrackedJob[];
|
|
214
|
+
} catch {
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function writeStorage(items: TrackedJob[]): void {
|
|
220
|
+
try {
|
|
221
|
+
sessionStorage.setItem(storageKey(), JSON.stringify(items));
|
|
222
|
+
} catch {
|
|
223
|
+
// storage unavailable / quota exceeded — the in-memory cache still serves
|
|
224
|
+
// this session; persistence is best-effort (mirrors mediaLibrary).
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function ensureLoaded(): TrackedJob[] {
|
|
229
|
+
if (cache == null) cache = readStorage();
|
|
230
|
+
return cache;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function emit(): void {
|
|
234
|
+
for (const l of listeners) l();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Terminal for polling purposes: done/failed OR the server forgot it. */
|
|
238
|
+
export function isJobTerminal(rec: TrackedJob): boolean {
|
|
239
|
+
return isTerminal(rec.status) || rec.expired;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Still worth polling: has an id, not terminal, not capped, not expired. */
|
|
243
|
+
export function isJobActive(rec: TrackedJob): boolean {
|
|
244
|
+
return (
|
|
245
|
+
rec.jobId != null &&
|
|
246
|
+
!isTerminal(rec.status) &&
|
|
247
|
+
!rec.capped &&
|
|
248
|
+
!rec.expired
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function anyActive(): boolean {
|
|
253
|
+
return ensureLoaded().some(isJobActive);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function capFor(
|
|
257
|
+
kind: JobKind,
|
|
258
|
+
nFrames?: number | null,
|
|
259
|
+
chain?: boolean | null,
|
|
260
|
+
segments?: number | null,
|
|
261
|
+
): number {
|
|
262
|
+
if (kind === "generate_movie")
|
|
263
|
+
return generateMoviePollCapMs(segments ?? 1, nFrames ?? undefined, chain ?? false);
|
|
264
|
+
if (kind === "generate_scene")
|
|
265
|
+
return generateScenePollCapMs(nFrames ?? 1, chain ?? false);
|
|
266
|
+
return kind === "generate_image" ? GENERATE_POLL_CAP_MS : JOB_POLL_CAP_MS;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function opFor(kind: JobKind): string {
|
|
270
|
+
switch (kind) {
|
|
271
|
+
case "crop":
|
|
272
|
+
return "crop.poll";
|
|
273
|
+
case "frame_extract":
|
|
274
|
+
return "frame_extract.poll";
|
|
275
|
+
case "audio_extract":
|
|
276
|
+
return "audio_extract.poll";
|
|
277
|
+
case "generate_image":
|
|
278
|
+
return "generate_image.poll";
|
|
279
|
+
case "generate_scene":
|
|
280
|
+
return "generate_scene.poll";
|
|
281
|
+
case "generate_movie":
|
|
282
|
+
return "generate_movie.poll";
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function failMsgFor(kind: JobKind): string {
|
|
287
|
+
switch (kind) {
|
|
288
|
+
case "crop":
|
|
289
|
+
return "Crop job failed.";
|
|
290
|
+
case "frame_extract":
|
|
291
|
+
return "Frame-extract job failed.";
|
|
292
|
+
case "audio_extract":
|
|
293
|
+
return "Audio-extract job failed.";
|
|
294
|
+
case "generate_image":
|
|
295
|
+
return "Generate job failed.";
|
|
296
|
+
case "generate_scene":
|
|
297
|
+
return "Scene generation failed.";
|
|
298
|
+
case "generate_movie":
|
|
299
|
+
return "Movie generation failed.";
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Apply a partial patch to one record by jobId; persist + emit on any change.
|
|
304
|
+
function mutate(jobId: string, patch: Partial<TrackedJob>): void {
|
|
305
|
+
const current = ensureLoaded();
|
|
306
|
+
let changed = false;
|
|
307
|
+
const next = current.map((rec) => {
|
|
308
|
+
if (rec.jobId !== jobId) return rec;
|
|
309
|
+
changed = true;
|
|
310
|
+
return { ...rec, ...patch };
|
|
311
|
+
});
|
|
312
|
+
if (!changed) return;
|
|
313
|
+
cache = next;
|
|
314
|
+
writeStorage(cache);
|
|
315
|
+
emit();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// The ONE centralized library push. Data-driven off the record (no stored
|
|
319
|
+
// closure) so it works even when a job completes after a page reload. mediaLibrary
|
|
320
|
+
// de-dups by ref.uri, so this single site can never double-add.
|
|
321
|
+
function pushOutputs(rec: TrackedJob, outputs: MediaRef[]): void {
|
|
322
|
+
if (rec.kind === "frame_extract") {
|
|
323
|
+
outputs.forEach((f, i) => addToLibrary(f, "frames", `frame ${i + 1}`));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (rec.kind === "crop") {
|
|
327
|
+
const out = outputs[0];
|
|
328
|
+
if (out) addToLibrary(out, rec.station, rec.libLabel ?? undefined);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (rec.kind === "generate_image") {
|
|
332
|
+
const out = outputs[0];
|
|
333
|
+
if (out)
|
|
334
|
+
addToLibrary(out, "generate", "generated", {
|
|
335
|
+
...(rec.prompt != null ? { prompt: rec.prompt } : {}),
|
|
336
|
+
...(rec.model != null ? { model: rec.model } : {}),
|
|
337
|
+
groupId: rec.jobId,
|
|
338
|
+
genKind: "generate_image",
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (rec.kind === "generate_scene") {
|
|
343
|
+
const prov = {
|
|
344
|
+
...(rec.prompt != null ? { prompt: rec.prompt } : {}),
|
|
345
|
+
...(rec.model != null ? { model: rec.model } : {}),
|
|
346
|
+
groupId: rec.jobId,
|
|
347
|
+
genKind: "generate_scene" as const,
|
|
348
|
+
};
|
|
349
|
+
outputs.forEach((m, i) => {
|
|
350
|
+
const isLast = i === outputs.length - 1;
|
|
351
|
+
if (isLast && m.kind === "video")
|
|
352
|
+
addToLibrary(m, "generate", "scene clip", prov);
|
|
353
|
+
else if (m.kind === "image")
|
|
354
|
+
addToLibrary(m, "generate", `scene frame ${i + 1}`, prov);
|
|
355
|
+
});
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (rec.kind === "generate_movie") {
|
|
359
|
+
const prov = {
|
|
360
|
+
...(rec.prompt != null ? { prompt: rec.prompt } : {}),
|
|
361
|
+
...(rec.model != null ? { model: rec.model } : {}),
|
|
362
|
+
groupId: rec.jobId,
|
|
363
|
+
genKind: "generate_movie" as const,
|
|
364
|
+
};
|
|
365
|
+
// Derive `segment N frame M` labels from the LAST movie progress (persisted on
|
|
366
|
+
// the record, so this is reload-safe): match each output frame to the segment
|
|
367
|
+
// whose take contained it by uri. Falls back to a global `movie frame N` when a
|
|
368
|
+
// frame isn't in the last progress snapshot; the assembled clip (video, LAST)
|
|
369
|
+
// is labelled "movie".
|
|
370
|
+
const labelByUri = new Map<string, string>();
|
|
371
|
+
if (rec.progress && isMovieProgress(rec.progress)) {
|
|
372
|
+
for (const seg of rec.progress.segments) {
|
|
373
|
+
(seg.frames ?? []).forEach((f, m) => {
|
|
374
|
+
labelByUri.set(f.uri, `segment ${seg.index + 1} frame ${m + 1}`);
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
outputs.forEach((m, i) => {
|
|
379
|
+
const isLast = i === outputs.length - 1;
|
|
380
|
+
if (isLast && m.kind === "video") addToLibrary(m, "generate", "movie", prov);
|
|
381
|
+
else if (m.kind === "image")
|
|
382
|
+
addToLibrary(m, "generate", labelByUri.get(m.uri) ?? `movie frame ${i + 1}`, prov);
|
|
383
|
+
});
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
// audio_extract: intermediate (feeds the temporal editor) — never pushed.
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Interpret one poll response with the SAME parse each hook used, branched by
|
|
390
|
+
// kind. `done` records outputs + runs the single push; `failed` records the
|
|
391
|
+
// error; a null status = the bus forgot the job → expired; anything else just
|
|
392
|
+
// advances the status.
|
|
393
|
+
function applyPoll(rec: TrackedJob, raw: unknown): void {
|
|
394
|
+
const parsed = jobRecordSchema.safeParse(raw);
|
|
395
|
+
if (!parsed.success) return; // malformed — retry next tick (matches hooks)
|
|
396
|
+
const jr = parsed.data;
|
|
397
|
+
if (jr.status === "failed") {
|
|
398
|
+
mutate(rec.jobId, {
|
|
399
|
+
status: "failed",
|
|
400
|
+
error: jr.result?.error?.message ?? failMsgFor(rec.kind),
|
|
401
|
+
progress: null,
|
|
402
|
+
});
|
|
403
|
+
} else if (jr.status === "cancelled") {
|
|
404
|
+
// Operator-stopped — terminal, but reported as "you stopped it", not a fault.
|
|
405
|
+
mutate(rec.jobId, {
|
|
406
|
+
status: "cancelled",
|
|
407
|
+
error: jr.result?.error?.message ?? "cancelled",
|
|
408
|
+
progress: null,
|
|
409
|
+
});
|
|
410
|
+
} else if (jr.status === "done") {
|
|
411
|
+
const outputs = jr.result?.outputs ?? [];
|
|
412
|
+
mutate(rec.jobId, {
|
|
413
|
+
status: "done",
|
|
414
|
+
outputs,
|
|
415
|
+
progress: null,
|
|
416
|
+
// The auto-archive destination, echoed on the terminal result (generation
|
|
417
|
+
// kinds). Surfaced as the "Saved to …" line on the finished preview.
|
|
418
|
+
project: jr.result?.project ?? null,
|
|
419
|
+
...(outputs.length === 0
|
|
420
|
+
? { error: `${rec.label} finished with no output.` }
|
|
421
|
+
: {}),
|
|
422
|
+
});
|
|
423
|
+
pushOutputs(rec, outputs);
|
|
424
|
+
} else if (jr.status == null) {
|
|
425
|
+
// Server no longer knows this job id (e.g. reload of a forgotten job). Do NOT
|
|
426
|
+
// crash and do NOT poll forever — mark expired and stop polling this record.
|
|
427
|
+
mutate(rec.jobId, { expired: true });
|
|
428
|
+
} else {
|
|
429
|
+
// Non-terminal (queued/claimed/running/cancelling): advance status AND carry
|
|
430
|
+
// the live progress so the descriptive readout + live frame gallery update.
|
|
431
|
+
mutate(rec.jobId, { status: jr.status, progress: jr.progress ?? null });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// One module-level tick: poll every active record, cap the overrun ones. A
|
|
436
|
+
// transport error is transient (matches the hooks) — skip and retry next tick.
|
|
437
|
+
async function pollTick(): Promise<void> {
|
|
438
|
+
for (const rec of ensureLoaded()) {
|
|
439
|
+
if (!isJobActive(rec)) continue;
|
|
440
|
+
if (
|
|
441
|
+
Date.now() - rec.startedAt >
|
|
442
|
+
capFor(rec.kind, rec.nFrames, rec.chain, rec.segments)
|
|
443
|
+
) {
|
|
444
|
+
// Cap reached — pause polling THIS record, but DON'T fail it.
|
|
445
|
+
mutate(rec.jobId, { capped: true });
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
const r = await request<unknown>(jobStatusUrl(rec.jobId), {
|
|
449
|
+
meta: { specKey: rec.station, operation: opFor(rec.kind) },
|
|
450
|
+
});
|
|
451
|
+
if (!r.ok) continue; // transient — retry on the next tick
|
|
452
|
+
applyPoll(rec, okValue(r));
|
|
453
|
+
}
|
|
454
|
+
disarmIfIdle();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Arm the single interval while ≥1 record is active; a no-op if already armed or
|
|
458
|
+
// nothing is active. Fires an immediate tick (matches the retired hooks).
|
|
459
|
+
function armPolling(): void {
|
|
460
|
+
if (typeof window === "undefined") return;
|
|
461
|
+
if (pollTimer != null) return;
|
|
462
|
+
if (!anyActive()) return;
|
|
463
|
+
// Ordering guard for the canned demo: this module's initTracker() runs at IMPORT
|
|
464
|
+
// time (before entry.tsx's installVideoDemo()), and on a reload it rehydrates any
|
|
465
|
+
// persisted in-flight record and immediately polls it. If the fetch shim isn't up
|
|
466
|
+
// yet, that first poll would hit the REAL backend (a 401 + a live wire hit — the
|
|
467
|
+
// brochure must never do that). installDemoTransport is idempotent, so calling it
|
|
468
|
+
// here guarantees the shim is installed before ANY canned poll fires, whatever the
|
|
469
|
+
// module import order. No-op outside canned mode.
|
|
470
|
+
if (isCanned()) installDemoTransport();
|
|
471
|
+
pollTimer = window.setInterval(() => void pollTick(), POLL_MS);
|
|
472
|
+
void pollTick();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Clear the interval once no record is active (keeps it alive for others).
|
|
476
|
+
function disarmIfIdle(): void {
|
|
477
|
+
if (pollTimer != null && !anyActive()) {
|
|
478
|
+
window.clearInterval(pollTimer);
|
|
479
|
+
pollTimer = null;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Fields the enqueue site hands the tracker right after it gets a job_id. */
|
|
484
|
+
export interface TrackJobInput {
|
|
485
|
+
jobId: string;
|
|
486
|
+
kind: JobKind;
|
|
487
|
+
station: StationId;
|
|
488
|
+
label: string;
|
|
489
|
+
enqueuedAt: number;
|
|
490
|
+
/** Stable per-row key for multi-job fan-out stations (e.g. the scene part key). */
|
|
491
|
+
rowKey?: string;
|
|
492
|
+
/** Library label to push on done (crop kinds); omit otherwise. */
|
|
493
|
+
libLabel?: string | null;
|
|
494
|
+
/** 1-based display index for multi-job stations. */
|
|
495
|
+
index?: number;
|
|
496
|
+
/** The crop region (crop / audio-crop) — kept so cards survive a reload. */
|
|
497
|
+
region?: SpatialRegion | TemporalRegion | null;
|
|
498
|
+
/**
|
|
499
|
+
* Requested frame count — scales the poll cap. For generate_scene it's n_frames;
|
|
500
|
+
* for generate_movie it's the timeline's TOTAL frame count (max end_frame).
|
|
501
|
+
*/
|
|
502
|
+
nFrames?: number;
|
|
503
|
+
/** Whether the job chains frames (sequential img2img) — adds poll time (scene/movie). */
|
|
504
|
+
chain?: boolean;
|
|
505
|
+
/** Segment count (goal-timeline length) for a generate_movie job — scales the poll cap. */
|
|
506
|
+
segments?: number;
|
|
507
|
+
/** Prompt summary that produced a generate_image/generate_scene/generate_movie job (provenance). */
|
|
508
|
+
prompt?: string;
|
|
509
|
+
/** Model id that produced a generate_image/generate_scene/generate_movie job (provenance). */
|
|
510
|
+
model?: string;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Register a freshly-enqueued job and ARM the polling loop. Called by each hook
|
|
515
|
+
* immediately after its enqueue POST returns a job_id. De-dups by jobId.
|
|
516
|
+
*/
|
|
517
|
+
export function trackJob(input: TrackJobInput): void {
|
|
518
|
+
if (!input.jobId) return;
|
|
519
|
+
const current = ensureLoaded();
|
|
520
|
+
if (current.some((rec) => rec.jobId === input.jobId)) return;
|
|
521
|
+
const now = Date.now();
|
|
522
|
+
const rec: TrackedJob = {
|
|
523
|
+
jobId: input.jobId,
|
|
524
|
+
rowKey: input.rowKey ?? null,
|
|
525
|
+
kind: input.kind,
|
|
526
|
+
station: input.station,
|
|
527
|
+
label: input.label,
|
|
528
|
+
enqueuedAt: input.enqueuedAt || now,
|
|
529
|
+
startedAt: now,
|
|
530
|
+
status: "queued",
|
|
531
|
+
capped: false,
|
|
532
|
+
expired: false,
|
|
533
|
+
error: null,
|
|
534
|
+
outputs: [],
|
|
535
|
+
libLabel: input.libLabel ?? null,
|
|
536
|
+
index: input.index ?? current.length + 1,
|
|
537
|
+
region: input.region ?? null,
|
|
538
|
+
nFrames: input.nFrames ?? null,
|
|
539
|
+
chain: input.chain ?? null,
|
|
540
|
+
segments: input.segments ?? null,
|
|
541
|
+
prompt: input.prompt ?? null,
|
|
542
|
+
model: input.model ?? null,
|
|
543
|
+
progress: null,
|
|
544
|
+
project: null,
|
|
545
|
+
};
|
|
546
|
+
cache = [...current, rec];
|
|
547
|
+
writeStorage(cache);
|
|
548
|
+
emit();
|
|
549
|
+
armPolling();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Cooperative cancel: fire-and-forget POST; the next poll observes the
|
|
554
|
+
* 'cancelling' → 'cancelled' transition (a running scene honors it between
|
|
555
|
+
* frames, so a long frame finishes first). Optimistically shows 'cancelling'
|
|
556
|
+
* so the button gives immediate feedback.
|
|
557
|
+
*/
|
|
558
|
+
export function cancelJob(jobId: string): void {
|
|
559
|
+
const rec = ensureLoaded().find((r) => r.jobId === jobId);
|
|
560
|
+
if (!rec || !isJobActive(rec)) return;
|
|
561
|
+
mutate(jobId, { status: "cancelling" });
|
|
562
|
+
void request<unknown>(jobCancelUrl(jobId), {
|
|
563
|
+
method: "POST",
|
|
564
|
+
meta: { specKey: rec.station, operation: "job.cancel" },
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Re-anchor the poll clock for a capped job and resume polling. Clears `capped`,
|
|
570
|
+
* keeps the last real status. No-op if the id isn't a capped record.
|
|
571
|
+
*/
|
|
572
|
+
export function resumeJob(jobId: string): void {
|
|
573
|
+
const current = ensureLoaded();
|
|
574
|
+
let changed = false;
|
|
575
|
+
const next = current.map((rec) => {
|
|
576
|
+
if (rec.jobId !== jobId || !rec.capped) return rec;
|
|
577
|
+
changed = true;
|
|
578
|
+
return { ...rec, capped: false, startedAt: Date.now() };
|
|
579
|
+
});
|
|
580
|
+
if (!changed) return;
|
|
581
|
+
cache = next;
|
|
582
|
+
writeStorage(cache);
|
|
583
|
+
emit();
|
|
584
|
+
armPolling();
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Resume EVERY capped job of one station (the no-key resume() form). */
|
|
588
|
+
export function resumeStation(station: StationId): void {
|
|
589
|
+
const current = ensureLoaded();
|
|
590
|
+
let changed = false;
|
|
591
|
+
const now = Date.now();
|
|
592
|
+
const next = current.map((rec) => {
|
|
593
|
+
if (rec.station !== station || !rec.capped) return rec;
|
|
594
|
+
changed = true;
|
|
595
|
+
return { ...rec, capped: false, startedAt: now };
|
|
596
|
+
});
|
|
597
|
+
if (!changed) return;
|
|
598
|
+
cache = next;
|
|
599
|
+
writeStorage(cache);
|
|
600
|
+
emit();
|
|
601
|
+
armPolling();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Forget a station's TERMINAL records (done / failed / expired). Called by a
|
|
606
|
+
* hook's reset() when a fresh source is picked, so the station's result grid
|
|
607
|
+
* starts clean — while any still-in-flight job of that station keeps polling and
|
|
608
|
+
* stays visible (house policy: this only forgets local rows, never server work).
|
|
609
|
+
*/
|
|
610
|
+
export function dismissTerminal(station: StationId): void {
|
|
611
|
+
const current = ensureLoaded();
|
|
612
|
+
const next = current.filter(
|
|
613
|
+
(rec) => !(rec.station === station && isJobTerminal(rec)),
|
|
614
|
+
);
|
|
615
|
+
if (next.length === current.length) return;
|
|
616
|
+
cache = next;
|
|
617
|
+
writeStorage(cache);
|
|
618
|
+
emit();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Forget EVERY station's TERMINAL records (done / failed / cancelled / expired) in
|
|
623
|
+
* one call — the user-facing "Clear finished" broom for the workbench's process
|
|
624
|
+
* list. Drops the station filter of dismissTerminal; any still-in-flight job (of
|
|
625
|
+
* any station) keeps polling and stays visible (house policy: this only forgets
|
|
626
|
+
* local rows, never server work).
|
|
627
|
+
*/
|
|
628
|
+
export function dismissAllTerminal(): void {
|
|
629
|
+
const current = ensureLoaded();
|
|
630
|
+
const next = current.filter((rec) => !isJobTerminal(rec));
|
|
631
|
+
if (next.length === current.length) return;
|
|
632
|
+
cache = next;
|
|
633
|
+
writeStorage(cache);
|
|
634
|
+
emit();
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Bind the cross-tab storage listener once (mirrors mediaLibrary). sessionStorage
|
|
638
|
+
// is per-tab so this mainly future-proofs the surface; if the key changes under
|
|
639
|
+
// us we re-sync + re-arm rather than serving a stale list.
|
|
640
|
+
function bindStorageOnce(): void {
|
|
641
|
+
if (storageBound || typeof window === "undefined") return;
|
|
642
|
+
storageBound = true;
|
|
643
|
+
window.addEventListener("storage", (e) => {
|
|
644
|
+
if (e.key !== null && e.key !== storageKey()) return;
|
|
645
|
+
cache = readStorage();
|
|
646
|
+
emit();
|
|
647
|
+
armPolling();
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Subscribe to tracker changes. Returns an unsubscribe. */
|
|
652
|
+
export function subscribeJobTracker(listener: () => void): () => void {
|
|
653
|
+
listeners.add(listener);
|
|
654
|
+
bindStorageOnce();
|
|
655
|
+
return () => {
|
|
656
|
+
listeners.delete(listener);
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** The current tracker snapshot (stable reference until it changes). */
|
|
661
|
+
export function getJobs(): TrackedJob[] {
|
|
662
|
+
return ensureLoaded();
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* React hook: the live tracker list, re-rendering on every mutation. Returns the
|
|
667
|
+
* WHOLE list — do NOT filter here; consumers filter with useMemo in their own
|
|
668
|
+
* body (filtering inside getSnapshot returns a fresh array each read → the
|
|
669
|
+
* "getSnapshot should be cached" warning / an infinite render loop).
|
|
670
|
+
*/
|
|
671
|
+
export function useJobTracker(): TrackedJob[] {
|
|
672
|
+
return useSyncExternalStore(subscribeJobTracker, getJobs, getJobs);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Module init: rehydrate from sessionStorage and RESUME polling for every
|
|
676
|
+
// non-terminal record (fixes reload amnesia). Non-capped in-flight records are
|
|
677
|
+
// re-anchored (startedAt = now) so a reload gives them a fresh poll window rather
|
|
678
|
+
// than instantly tripping the cap; capped records are left capped (they show the
|
|
679
|
+
// resume affordance and DON'T auto-resume); expired/terminal records are left
|
|
680
|
+
// as-is. Runs once on import — the hooks + sidebar all import this module, so it
|
|
681
|
+
// always loads on boot.
|
|
682
|
+
function initTracker(): void {
|
|
683
|
+
if (typeof window === "undefined") return;
|
|
684
|
+
bindStorageOnce();
|
|
685
|
+
const loaded = ensureLoaded();
|
|
686
|
+
let mutated = false;
|
|
687
|
+
const now = Date.now();
|
|
688
|
+
const next = loaded.map((rec) => {
|
|
689
|
+
if (isJobActive(rec)) {
|
|
690
|
+
mutated = true;
|
|
691
|
+
return { ...rec, startedAt: now };
|
|
692
|
+
}
|
|
693
|
+
return rec;
|
|
694
|
+
});
|
|
695
|
+
if (mutated) {
|
|
696
|
+
cache = next;
|
|
697
|
+
writeStorage(cache);
|
|
698
|
+
}
|
|
699
|
+
armPolling();
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
initTracker();
|