@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.
Files changed (177) hide show
  1. package/LICENSE +41 -0
  2. package/assets/sampels/images/bycycle/bd8603bad35d4f179f4d1134d8faf2ee.mp4 +0 -0
  3. package/assets/sampels/images/bycycle/frame_00000.png +0 -0
  4. package/assets/sampels/images/bycycle/frame_00001.png +0 -0
  5. package/assets/sampels/images/bycycle/frame_00002.png +0 -0
  6. package/assets/sampels/images/bycycle/frame_00003.png +0 -0
  7. package/assets/sampels/images/bycycle/frame_00004.png +0 -0
  8. package/assets/sampels/images/bycycle/frame_00005.png +0 -0
  9. package/assets/sampels/images/forrest/ff23090611b844a6b85c224f388bd542.mp4 +0 -0
  10. package/assets/sampels/images/forrest/frame_00000.png +0 -0
  11. package/assets/sampels/images/forrest/frame_00001.png +0 -0
  12. package/assets/sampels/images/forrest/frame_00002.png +0 -0
  13. package/assets/sampels/images/forrest/frame_00003.png +0 -0
  14. package/assets/sampels/images/forrest/frame_00004.png +0 -0
  15. package/assets/sampels/images/forrest/frame_00005.png +0 -0
  16. package/assets/sampels/images/shenzo/e1a4fcd623084ab4a986d11c8cd00db8.mp4 +0 -0
  17. package/assets/sampels/images/shenzo/frame_00000.png +0 -0
  18. package/assets/sampels/images/shenzo/frame_00001.png +0 -0
  19. package/assets/sampels/images/shenzo/frame_00002.png +0 -0
  20. package/assets/sampels/images/shenzo/frame_00003.png +0 -0
  21. package/assets/sampels/images/shenzo/frame_00004.png +0 -0
  22. package/assets/sampels/images/shenzo/frame_00005.png +0 -0
  23. package/assets/sampels/scenes/man.mp4 +0 -0
  24. package/assets/sampels/scenes/woman.mp4 +0 -0
  25. package/assets/sampels/studio_movies/caverns/_assembly/contrib_00.mp4 +0 -0
  26. package/assets/sampels/studio_movies/caverns/movie.json +67 -0
  27. package/assets/sampels/studio_movies/caverns/movie.mp4 +0 -0
  28. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/clip.mp4 +0 -0
  29. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/manifest.json +87 -0
  30. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/provenance.json +7 -0
  31. package/assets/sampels/studio_movies/caverns/segment_01/branch.png +0 -0
  32. package/assets/sampels/studio_movies/keeper-matrix/_assembly/concat_list.txt +2 -0
  33. package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_00.mp4 +0 -0
  34. package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_01.mp4 +0 -0
  35. package/assets/sampels/studio_movies/keeper-matrix/movie.json +81 -0
  36. package/assets/sampels/studio_movies/keeper-matrix/movie.mp4 +0 -0
  37. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/clip.mp4 +0 -0
  38. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/manifest.json +87 -0
  39. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/provenance.json +7 -0
  40. package/assets/sampels/studio_movies/keeper-matrix/segment_01/branch.png +0 -0
  41. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/clip.mp4 +0 -0
  42. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/manifest.json +87 -0
  43. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/provenance.json +7 -0
  44. package/assets/sampels/videos/a_red_paper_boat_drifting_on_a_calm_pond__gentle_ripples__mo_a62d6c28.mp4 +0 -0
  45. package/assets/sampels/videos/aurora.mp4 +0 -0
  46. package/assets/sampels/videos/dark_night.mp4 +0 -0
  47. package/assets/sampels/videos/fire_and_soul.mp4 +0 -0
  48. package/assets/sampels/videos/harsh_seas.mp4 +0 -0
  49. package/entry.tsx +74 -0
  50. package/index.html +53 -0
  51. package/package.json +42 -0
  52. package/src/assets/hugpy-mark.png +0 -0
  53. package/src/config.ts +676 -0
  54. package/src/demo/DemoBanner.tsx +54 -0
  55. package/src/demo/assets/demo-audio.mp3 +0 -0
  56. package/src/demo/assets/demo-clip.mp4 +0 -0
  57. package/src/demo/assets/demo-crop.mp4 +0 -0
  58. package/src/demo/assets/demo-poster.jpg +0 -0
  59. package/src/demo/assets/frame-1.jpg +0 -0
  60. package/src/demo/assets/frame-2.jpg +0 -0
  61. package/src/demo/assets/frame-3.jpg +0 -0
  62. package/src/demo/assets/frame-4.jpg +0 -0
  63. package/src/demo/assets/frame-5.jpg +0 -0
  64. package/src/demo/assets/frame-6.jpg +0 -0
  65. package/src/demo/assets/gen-1.jpg +0 -0
  66. package/src/demo/assets/gen-2.jpg +0 -0
  67. package/src/demo/assets.d.ts +14 -0
  68. package/src/demo/demo.css +64 -0
  69. package/src/demo/demoFetch.ts +305 -0
  70. package/src/demo/fixtures.ts +814 -0
  71. package/src/demo/index.ts +43 -0
  72. package/src/demo/mode.ts +81 -0
  73. package/src/demo/promptAssist.ts +181 -0
  74. package/src/demo/seed.ts +97 -0
  75. package/src/nav/BrandMark.css +66 -0
  76. package/src/nav/BrandMark.tsx +40 -0
  77. package/src/nav/Navbar.css +13 -0
  78. package/src/nav/Navbar.tsx +68 -0
  79. package/src/regions/SpatialRegionEditor.tsx +375 -0
  80. package/src/regions/TemporalRegionEditor.tsx +485 -0
  81. package/src/regions/types.ts +85 -0
  82. package/src/session.ts +85 -0
  83. package/src/share.ts +77 -0
  84. package/src/stations/ActivePanel.tsx +55 -0
  85. package/src/stations/ActiveProcessesStation.tsx +272 -0
  86. package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +228 -0
  87. package/src/stations/AddFileBar.tsx +59 -0
  88. package/src/stations/AudioCropStation.tsx +157 -0
  89. package/src/stations/ComfyPanel.tsx +210 -0
  90. package/src/stations/ComfyStation.tsx +196 -0
  91. package/src/stations/CondensedKnobStrip.tsx +245 -0
  92. package/src/stations/ConsolePanel.tsx +82 -0
  93. package/src/stations/FrameExtractStation.tsx +148 -0
  94. package/src/stations/GenIdentityBar.tsx +412 -0
  95. package/src/stations/GenerateStation.tsx +5603 -0
  96. package/src/stations/GenerateStation.tsx.bak-tester-removal +5170 -0
  97. package/src/stations/GoalComposer.tsx +139 -0
  98. package/src/stations/ImageCropStation.tsx +153 -0
  99. package/src/stations/KnobInfo.tsx +64 -0
  100. package/src/stations/PlaceholderStation.tsx +26 -0
  101. package/src/stations/SectionTabs.tsx +276 -0
  102. package/src/stations/SharePanel.tsx +298 -0
  103. package/src/stations/SidebarRegistry.tsx +105 -0
  104. package/src/stations/StationShell.tsx +96 -0
  105. package/src/stations/StudioClipsStation.tsx +28 -0
  106. package/src/stations/WorkbenchDrawer.tsx +66 -0
  107. package/src/stations/WorkbenchSidebar.tsx +1224 -0
  108. package/src/stations/WorkbenchStation.tsx +91 -0
  109. package/src/stations/comfyEmbed.ts +79 -0
  110. package/src/stations/drawerShell.tsx +419 -0
  111. package/src/stations/ops/AudioCropCore.tsx +218 -0
  112. package/src/stations/ops/CharacterGroupsPanel.tsx +716 -0
  113. package/src/stations/ops/FrameExtractCore.tsx +695 -0
  114. package/src/stations/ops/ImageCropCore.tsx +164 -0
  115. package/src/stations/ops/VideoCropCore.tsx +193 -0
  116. package/src/stations/ops/useVideoCropJobs.ts +224 -0
  117. package/src/stations/pollCaps.ts +87 -0
  118. package/src/stations/registry.ts +191 -0
  119. package/src/stations/studio/AngleRingEditor.tsx +159 -0
  120. package/src/stations/studio/IdentitiesStation.tsx +2455 -0
  121. package/src/stations/studio/IdentityProfileControls.tsx +262 -0
  122. package/src/stations/studio/StudioActiveProcesses.tsx +224 -0
  123. package/src/stations/studio/StudioAssistLogPanel.tsx +220 -0
  124. package/src/stations/studio/StudioGenerateMode.tsx +29 -0
  125. package/src/stations/studio/StudioGenerateTab.tsx +2688 -0
  126. package/src/stations/studio/StudioItemCard.tsx +334 -0
  127. package/src/stations/studio/StudioLibraryTab.tsx +506 -0
  128. package/src/stations/studio/StudioMovieComposer.tsx +2875 -0
  129. package/src/stations/studio/StudioMovieComposer.tsx.bak-sessions-20260812 +2729 -0
  130. package/src/stations/studio/StudioMovieSessions.tsx +553 -0
  131. package/src/stations/studio/StudioMovieSessions.tsx.bak-sessions-20260812 +417 -0
  132. package/src/stations/studio/StudioPlane.tsx +302 -0
  133. package/src/stations/studio/StudioSplicedRow.tsx +476 -0
  134. package/src/stations/studio/StudioViewer.tsx +339 -0
  135. package/src/stations/studio/TurntableViewer.tsx +220 -0
  136. package/src/stations/studio/movieTimeline.ts +198 -0
  137. package/src/stations/studio/promptCard.tsx +498 -0
  138. package/src/stations/studio/studioItem.ts +87 -0
  139. package/src/stations/studio/studioShared.tsx +821 -0
  140. package/src/stations/studio/useIdentityProfiles.ts +575 -0
  141. package/src/stations/studio/useReconstruction.ts +283 -0
  142. package/src/stations/types.ts +43 -0
  143. package/src/stations/useAudioCropJobs.ts +202 -0
  144. package/src/stations/useAudioExtractJob.ts +144 -0
  145. package/src/stations/useCropJobs.ts +206 -0
  146. package/src/stations/useFrameJobs.ts +140 -0
  147. package/src/stations/useGenerateJob.ts +151 -0
  148. package/src/stations/useGenerateMovieJob.ts +177 -0
  149. package/src/stations/useGenerateRowJobs.ts +184 -0
  150. package/src/stations/useGenerateSceneJob.ts +156 -0
  151. package/src/stations/useGenerateSceneRowJobs.ts +282 -0
  152. package/src/style/app.css +4974 -0
  153. package/src/transport/client.ts +262 -0
  154. package/src/transport/telemetry.ts +97 -0
  155. package/src/video/DropReceptacle.tsx +122 -0
  156. package/src/video/PromptAssistButtons.tsx +118 -0
  157. package/src/video/composerBridge.ts +66 -0
  158. package/src/video/contract.ts +599 -0
  159. package/src/video/jobTracker.ts +702 -0
  160. package/src/video/mediaLibrary.ts +328 -0
  161. package/src/video/renderCompat.ts +512 -0
  162. package/src/video/sessionForm.ts +189 -0
  163. package/src/video/spreadAssist.ts +392 -0
  164. package/src/video/studioBridge.ts +82 -0
  165. package/src/video/useMediaJobs.ts +279 -0
  166. package/src/video/useModels.ts +413 -0
  167. package/src/video/useMoviePresets.ts +97 -0
  168. package/src/video/useMovieSessions.ts +292 -0
  169. package/src/video/usePresets.ts +167 -0
  170. package/src/video/useProjects.ts +87 -0
  171. package/src/video/usePromptAssist.ts +394 -0
  172. package/src/video/useRenderPresets.ts +221 -0
  173. package/src/video/useStudioAssistLog.ts +266 -0
  174. package/src/video/useStudioPresets.ts +128 -0
  175. package/src/video/useWorkerCalls.ts +165 -0
  176. package/tsconfig.json +17 -0
  177. 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();