@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,206 @@
1
+ // The crop station's enqueue surface, now riding the session-wide job tracker.
2
+ // It still owns the request-body construction (one crop job per queued region)
3
+ // and POSTs each through request<T>() + config URLs; the moment it has a job_id
4
+ // it hands the job to jobTracker.trackJob and stops owning any polling or job
5
+ // state. Polling, the poll cap, the terminal parse, and the single library push
6
+ // all live in jobTracker now — so a crop enqueued here keeps running (and lands
7
+ // in the library) even after the user switches sub-tabs (which unmounts this
8
+ // station) or reloads the page.
9
+ //
10
+ // The public API to ImageCropStation is UNCHANGED: `{ jobs, runCrops, resume,
11
+ // reset }` where each job carries { key, index, region, jobId, status, output,
12
+ // error, capped, startedAt }. `jobs` is now DERIVED from the tracker (this
13
+ // station's crop records) merged with any still-enqueuing local placeholders.
14
+ import { useCallback, useMemo, useRef, useState } from "react";
15
+ import { request, okValue, errorOf, describeAppError } from "../transport/client";
16
+ import { hugpyConfig } from "../config";
17
+ import {
18
+ enqueueResultSchema,
19
+ type CropJobRequest,
20
+ type JobStatus,
21
+ type MediaRef,
22
+ } from "../video/contract";
23
+ import {
24
+ useJobTracker,
25
+ trackJob,
26
+ resumeJob,
27
+ resumeStation,
28
+ dismissTerminal,
29
+ type TrackedJob as TrackerRecord,
30
+ } from "../video/jobTracker";
31
+ import type { SpatialRegion } from "../regions/types";
32
+
33
+ export interface TrackedJob {
34
+ /** Stable local key (the server job id once enqueued). */
35
+ key: string;
36
+ /** 1-based position, for display. */
37
+ index: number;
38
+ /** The native-px region this job crops. */
39
+ region: SpatialRegion;
40
+ /** Server job id once enqueued, else null. */
41
+ jobId: string | null;
42
+ /** Latest polled status (null = enqueued but not yet claimed). */
43
+ status: JobStatus;
44
+ /** Result crop MediaRef on success. */
45
+ output: MediaRef | null;
46
+ /** Human message on failure (enqueue error or job error). */
47
+ error: string | null;
48
+ /** Poll cap reached while non-terminal — NOT a failure (Phase 8). */
49
+ capped: boolean;
50
+ /** Enqueue timestamp — the anchor for the poll cap. */
51
+ startedAt: number;
52
+ }
53
+
54
+ // A pre-enqueue placeholder: the brief window between the user clicking Run and
55
+ // the enqueue POST returning a job_id (or failing). Once a job_id arrives the
56
+ // tracker owns the job and the placeholder is dropped; a failed enqueue keeps its
57
+ // placeholder (as a failed row) since it never became a tracker record.
58
+ interface Pending {
59
+ key: string;
60
+ index: number;
61
+ region: SpatialRegion;
62
+ status: Extract<JobStatus, "queued" | "failed">;
63
+ error: string | null;
64
+ startedAt: number;
65
+ }
66
+
67
+ let _seq = 0;
68
+ function makeKey(): string {
69
+ _seq += 1;
70
+ return `job_${_seq.toString(36)}_${Date.now().toString(36)}`;
71
+ }
72
+
73
+ export interface CropJobsApi {
74
+ jobs: TrackedJob[];
75
+ /** Enqueue one crop job per region and begin polling (via the tracker). */
76
+ runCrops: (regions: SpatialRegion[]) => void;
77
+ /**
78
+ * Re-anchor the poll clock for a capped job (or every capped crop when `key`
79
+ * is omitted) and resume polling. Clears `capped`, keeps status.
80
+ */
81
+ resume: (key?: string) => void;
82
+ /** Forget this station's finished jobs (called when a new source is ingested). */
83
+ reset: () => void;
84
+ }
85
+
86
+ const STATION = "image-crop";
87
+
88
+ export function useCropJobs(source: MediaRef | null): CropJobsApi {
89
+ const records = useJobTracker();
90
+ const [pending, setPending] = useState<Pending[]>([]);
91
+
92
+ // This station's crop records, newest-index last (stable via useMemo).
93
+ const mine = useMemo(
94
+ () => records.filter((r) => r.station === STATION && r.kind === "crop"),
95
+ [records],
96
+ );
97
+
98
+ const mineRef = useRef<TrackerRecord[]>(mine);
99
+ mineRef.current = mine;
100
+ const pendingRef = useRef<Pending[]>(pending);
101
+ pendingRef.current = pending;
102
+
103
+ const jobs = useMemo<TrackedJob[]>(() => {
104
+ const fromTracker: TrackedJob[] = mine.map((r) => ({
105
+ key: r.jobId,
106
+ index: r.index,
107
+ region: (r.region as SpatialRegion) ?? { x: 0, y: 0, w: 0, h: 0 },
108
+ jobId: r.jobId,
109
+ status: r.status,
110
+ output: r.outputs[0] ?? null,
111
+ error: r.error,
112
+ capped: r.capped,
113
+ startedAt: r.startedAt,
114
+ }));
115
+ const fromPending: TrackedJob[] = pending.map((p) => ({
116
+ key: p.key,
117
+ index: p.index,
118
+ region: p.region,
119
+ jobId: null,
120
+ status: p.status,
121
+ output: null,
122
+ error: p.error,
123
+ capped: false,
124
+ startedAt: p.startedAt,
125
+ }));
126
+ return [...fromTracker, ...fromPending].sort((a, b) => a.index - b.index);
127
+ }, [mine, pending]);
128
+
129
+ const reset = useCallback(() => {
130
+ setPending([]);
131
+ dismissTerminal(STATION);
132
+ }, []);
133
+
134
+ const resume = useCallback((key?: string) => {
135
+ if (key != null) resumeJob(key);
136
+ else resumeStation(STATION);
137
+ }, []);
138
+
139
+ const runCrops = useCallback(
140
+ (regions: SpatialRegion[]) => {
141
+ if (!source || regions.length === 0) return;
142
+ // Continue the 1-based numbering past any existing/pending rows.
143
+ const base = Math.max(
144
+ 0,
145
+ ...mineRef.current.map((r) => r.index),
146
+ ...pendingRef.current.map((p) => p.index),
147
+ );
148
+
149
+ regions.forEach((region, i) => {
150
+ const key = makeKey();
151
+ const index = base + i + 1;
152
+ const enqueuedAt = Date.now();
153
+ const label = `crop ${region.w}×${region.h}`;
154
+ setPending((prev) => [
155
+ ...prev,
156
+ { key, index, region, status: "queued", error: null, startedAt: enqueuedAt },
157
+ ]);
158
+
159
+ const body: CropJobRequest = { source, spatial: region, temporal: null };
160
+ void request<unknown>(hugpyConfig.cropEnqueueUrl, {
161
+ method: "POST",
162
+ body: JSON.stringify(body),
163
+ headers: { "Content-Type": "application/json" },
164
+ meta: { specKey: STATION, operation: "crop.enqueue" },
165
+ }).then((r) => {
166
+ if (!r.ok) {
167
+ setPending((prev) =>
168
+ prev.map((p) =>
169
+ p.key === key
170
+ ? { ...p, status: "failed", error: describeAppError(errorOf(r)) }
171
+ : p,
172
+ ),
173
+ );
174
+ return;
175
+ }
176
+ const parsed = enqueueResultSchema.safeParse(okValue(r));
177
+ if (!parsed.success) {
178
+ setPending((prev) =>
179
+ prev.map((p) =>
180
+ p.key === key
181
+ ? { ...p, status: "failed", error: "Malformed enqueue response." }
182
+ : p,
183
+ ),
184
+ );
185
+ return;
186
+ }
187
+ // Hand the job to the durable tracker, then drop the local placeholder.
188
+ trackJob({
189
+ jobId: parsed.data.job_id,
190
+ kind: "crop",
191
+ station: STATION,
192
+ label,
193
+ libLabel: label,
194
+ enqueuedAt,
195
+ index,
196
+ region,
197
+ });
198
+ setPending((prev) => prev.filter((p) => p.key !== key));
199
+ });
200
+ });
201
+ },
202
+ [source],
203
+ );
204
+
205
+ return { jobs, runCrops, resume, reset };
206
+ }
@@ -0,0 +1,140 @@
1
+ // The Frames station's enqueue surface, now riding the session-wide job tracker.
2
+ // frame-extract is ONE job that produces MANY frames. This hook still owns the
3
+ // request-body construction and POSTs it through request<T>() + config URLs; the
4
+ // moment it has a job_id it hands the job to jobTracker.trackJob and stops owning
5
+ // any polling or job state. Polling, the poll cap, the terminal parse, and the
6
+ // single "push every produced frame into the library" step all live in
7
+ // jobTracker now — so an extraction kicked off here keeps running (and its frames
8
+ // land in the shared library) even after the user switches sub-tabs (which
9
+ // unmounts this station) or reloads the page.
10
+ //
11
+ // The public API to FrameExtractStation is UNCHANGED:
12
+ // `{ jobId, status, frames, error, capped, running, run, resume, reset }`.
13
+ // Those values are DERIVED from the tracker (this station's newest frame-extract
14
+ // record) plus a little local state for the brief pre-enqueue window.
15
+ import { useCallback, useMemo, useRef, useState } from "react";
16
+ import { request, okValue, errorOf, describeAppError } from "../transport/client";
17
+ import { hugpyConfig } from "../config";
18
+ import {
19
+ enqueueResultSchema,
20
+ type FrameExtractRequest,
21
+ type JobStatus,
22
+ type MediaRef,
23
+ } from "../video/contract";
24
+ import {
25
+ useJobTracker,
26
+ trackJob,
27
+ resumeJob,
28
+ dismissTerminal,
29
+ isJobActive,
30
+ } from "../video/jobTracker";
31
+
32
+ export interface FrameJobsApi {
33
+ /** Server job id once enqueued, else null. */
34
+ jobId: string | null;
35
+ /** Latest polled status (null = idle or enqueued-but-not-yet-claimed). */
36
+ status: JobStatus;
37
+ /** The produced frame MediaRefs (populated on done). */
38
+ frames: MediaRef[];
39
+ /** Human message on failure (enqueue error or job error). */
40
+ error: string | null;
41
+ /** Poll cap reached while non-terminal (Phase 8) — NOT a failure. */
42
+ capped: boolean;
43
+ /** True while enqueuing or polling a non-terminal job. */
44
+ running: boolean;
45
+ /** Enqueue ONE frame-extract job and begin polling. Replaces any prior run. */
46
+ run: (req: FrameExtractRequest) => void;
47
+ /** Re-anchor the poll clock and resume polling a capped job. Clears `capped`. */
48
+ resume: () => void;
49
+ /** Forget this station's finished job (called when a new source is ingested). */
50
+ reset: () => void;
51
+ }
52
+
53
+ const STATION = "frames";
54
+
55
+ export function useFrameJobs(): FrameJobsApi {
56
+ const records = useJobTracker();
57
+ const [currentJobId, setCurrentJobId] = useState<string | null>(null);
58
+ const [enqueuing, setEnqueuing] = useState<boolean>(false);
59
+ const [enqueueError, setEnqueueError] = useState<string | null>(null);
60
+
61
+ // The record this hook instance is watching: the one it just enqueued
62
+ // (currentJobId), or — on a fresh mount after a tab switch / reload, when the
63
+ // local pointer is gone — the newest frame-extract record for this station.
64
+ const record = useMemo(() => {
65
+ const mine = records.filter(
66
+ (r) => r.station === STATION && r.kind === "frame_extract",
67
+ );
68
+ if (currentJobId) return mine.find((r) => r.jobId === currentJobId) ?? null;
69
+ if (enqueuing) return null; // a fresh run is in flight — don't resurface the old one
70
+ return mine.slice().sort((a, b) => b.enqueuedAt - a.enqueuedAt)[0] ?? null;
71
+ }, [records, currentJobId, enqueuing]);
72
+
73
+ const jobIdRef = useRef<string | null>(record?.jobId ?? null);
74
+ jobIdRef.current = record?.jobId ?? null;
75
+
76
+ const reset = useCallback(() => {
77
+ setCurrentJobId(null);
78
+ setEnqueuing(false);
79
+ setEnqueueError(null);
80
+ dismissTerminal(STATION);
81
+ }, []);
82
+
83
+ const resume = useCallback(() => {
84
+ const id = jobIdRef.current;
85
+ if (id) resumeJob(id);
86
+ }, []);
87
+
88
+ const run = useCallback((req: FrameExtractRequest) => {
89
+ // Fresh run — hide any prior result and enqueue one job.
90
+ setCurrentJobId(null);
91
+ setEnqueueError(null);
92
+ setEnqueuing(true);
93
+ const enqueuedAt = Date.now();
94
+
95
+ void request<unknown>(hugpyConfig.frameExtractEnqueueUrl, {
96
+ method: "POST",
97
+ body: JSON.stringify(req),
98
+ headers: { "Content-Type": "application/json" },
99
+ meta: { specKey: STATION, operation: "frame_extract.enqueue" },
100
+ }).then((r) => {
101
+ setEnqueuing(false);
102
+ if (!r.ok) {
103
+ setEnqueueError(describeAppError(errorOf(r)));
104
+ return;
105
+ }
106
+ const parsed = enqueueResultSchema.safeParse(okValue(r));
107
+ if (!parsed.success) {
108
+ setEnqueueError("Malformed enqueue response.");
109
+ return;
110
+ }
111
+ const jobId = parsed.data.job_id;
112
+ trackJob({
113
+ jobId,
114
+ kind: "frame_extract",
115
+ station: STATION,
116
+ label: `frames @ ${req.fps} fps`,
117
+ enqueuedAt,
118
+ });
119
+ setCurrentJobId(jobId);
120
+ });
121
+ }, []);
122
+
123
+ const status: JobStatus = enqueueError ? "failed" : record?.status ?? null;
124
+ const error =
125
+ enqueueError ??
126
+ record?.error ??
127
+ (record?.expired ? "This job is no longer tracked by the server." : null);
128
+
129
+ return {
130
+ jobId: record?.jobId ?? null,
131
+ status,
132
+ frames: record?.outputs ?? [],
133
+ error,
134
+ capped: record?.capped ?? false,
135
+ running: enqueuing || (record != null && isJobActive(record)),
136
+ run,
137
+ resume,
138
+ reset,
139
+ };
140
+ }
@@ -0,0 +1,151 @@
1
+ // The Generate station's enqueue surface, now riding the session-wide job
2
+ // tracker. ONE ordered multimodal prompt → ONE generate job → ONE image. This
3
+ // hook still owns the request-body construction and POSTs it through request<T>()
4
+ // + config URLs; the moment it has a job_id it hands the job to
5
+ // jobTracker.trackJob and stops owning any polling or job state. Polling, the
6
+ // generate poll cap (GENERATE_POLL_CAP_MS), the terminal parse, and the single
7
+ // library push (origin "generate") all live in jobTracker now — so a generation
8
+ // kicked off here keeps running (and lands in the library) even after the user
9
+ // switches sub-tabs (which unmounts this station) or reloads the page.
10
+ //
11
+ // The public API to GenerateStation is UNCHANGED:
12
+ // `{ jobId, status, output, error, capped, running, run, resume, reset }`.
13
+ import { useCallback, useMemo, useRef, useState } from "react";
14
+ import { request, okValue, errorOf, describeAppError } from "../transport/client";
15
+ import { hugpyConfig } from "../config";
16
+ import {
17
+ enqueueResultSchema,
18
+ promptSummary,
19
+ isMovieProgress,
20
+ type GenerateImageRequest,
21
+ type JobStatus,
22
+ type MediaRef,
23
+ type JobProgress,
24
+ type JobProject,
25
+ } from "../video/contract";
26
+ import {
27
+ useJobTracker,
28
+ trackJob,
29
+ resumeJob,
30
+ dismissTerminal,
31
+ isJobActive,
32
+ } from "../video/jobTracker";
33
+
34
+ export interface GenerateJobApi {
35
+ /** Server job id once enqueued, else null. */
36
+ jobId: string | null;
37
+ /** Latest polled status (null = idle or enqueued-but-not-yet-claimed). */
38
+ status: JobStatus;
39
+ /** The generated image MediaRef (populated on done). */
40
+ output: MediaRef | null;
41
+ /** Human message on failure (enqueue error or job error). */
42
+ error: string | null;
43
+ /** Poll cap reached while non-terminal (Phase 8) — NOT a failure. */
44
+ capped: boolean;
45
+ /** True while enqueuing or polling a non-terminal job. */
46
+ running: boolean;
47
+ /** Live per-run progress while running (done/total, stage, step); null otherwise. */
48
+ progress: JobProgress | null;
49
+ /** Auto-archive project this run was saved into (populated on done); null otherwise. */
50
+ project: JobProject | null;
51
+ /** Enqueue ONE generate job and begin polling. Replaces any prior run. */
52
+ run: (req: GenerateImageRequest) => void;
53
+ /** Re-anchor the poll clock and resume polling a capped job. Clears `capped`. */
54
+ resume: () => void;
55
+ /** Forget this station's finished job (called when the composer is cleared). */
56
+ reset: () => void;
57
+ }
58
+
59
+ const STATION = "generate";
60
+
61
+ export function useGenerateJob(): GenerateJobApi {
62
+ const records = useJobTracker();
63
+ const [currentJobId, setCurrentJobId] = useState<string | null>(null);
64
+ const [enqueuing, setEnqueuing] = useState<boolean>(false);
65
+ const [enqueueError, setEnqueueError] = useState<string | null>(null);
66
+
67
+ const record = useMemo(() => {
68
+ const mine = records.filter(
69
+ (r) => r.station === STATION && r.kind === "generate_image",
70
+ );
71
+ if (currentJobId) return mine.find((r) => r.jobId === currentJobId) ?? null;
72
+ if (enqueuing) return null;
73
+ return mine.slice().sort((a, b) => b.enqueuedAt - a.enqueuedAt)[0] ?? null;
74
+ }, [records, currentJobId, enqueuing]);
75
+
76
+ const jobIdRef = useRef<string | null>(record?.jobId ?? null);
77
+ jobIdRef.current = record?.jobId ?? null;
78
+
79
+ const reset = useCallback(() => {
80
+ setCurrentJobId(null);
81
+ setEnqueuing(false);
82
+ setEnqueueError(null);
83
+ dismissTerminal(STATION);
84
+ }, []);
85
+
86
+ const resume = useCallback(() => {
87
+ const id = jobIdRef.current;
88
+ if (id) resumeJob(id);
89
+ }, []);
90
+
91
+ const run = useCallback((req: GenerateImageRequest) => {
92
+ setCurrentJobId(null);
93
+ setEnqueueError(null);
94
+ setEnqueuing(true);
95
+ const enqueuedAt = Date.now();
96
+
97
+ void request<unknown>(hugpyConfig.generateEnqueueUrl, {
98
+ method: "POST",
99
+ body: JSON.stringify(req),
100
+ headers: { "Content-Type": "application/json" },
101
+ meta: { specKey: STATION, operation: "generate_image.enqueue" },
102
+ }).then((r) => {
103
+ setEnqueuing(false);
104
+ if (!r.ok) {
105
+ setEnqueueError(describeAppError(errorOf(r)));
106
+ return;
107
+ }
108
+ const parsed = enqueueResultSchema.safeParse(okValue(r));
109
+ if (!parsed.success) {
110
+ setEnqueueError("Malformed enqueue response.");
111
+ return;
112
+ }
113
+ const jobId = parsed.data.job_id;
114
+ trackJob({
115
+ jobId,
116
+ kind: "generate_image",
117
+ station: STATION,
118
+ label: "generate image",
119
+ enqueuedAt,
120
+ prompt: promptSummary(req.parts),
121
+ model: req.model_id,
122
+ });
123
+ setCurrentJobId(jobId);
124
+ });
125
+ }, []);
126
+
127
+ const status: JobStatus = enqueueError ? "failed" : record?.status ?? null;
128
+ const error =
129
+ enqueueError ??
130
+ record?.error ??
131
+ (record?.expired ? "This job is no longer tracked by the server." : null);
132
+
133
+ // A generate_image record only ever carries flat progress; narrow off the tracker's
134
+ // union (never a nested movie blob for this kind).
135
+ const progress: JobProgress | null =
136
+ record?.progress && !isMovieProgress(record.progress) ? record.progress : null;
137
+
138
+ return {
139
+ jobId: record?.jobId ?? null,
140
+ status,
141
+ output: record?.outputs[0] ?? null,
142
+ error,
143
+ capped: record?.capped ?? false,
144
+ running: enqueuing || (record != null && isJobActive(record)),
145
+ progress,
146
+ project: record?.project ?? null,
147
+ run,
148
+ resume,
149
+ reset,
150
+ };
151
+ }
@@ -0,0 +1,177 @@
1
+ // The Generate station's MOVIE enqueue surface — Movie mode's sibling to
2
+ // useGenerateSceneJob. ONE goal timeline → ONE generate_movie job → N segments of
3
+ // consecutive frames + one assembled mp4 clip (LAST), with an opt-in vision
4
+ // director loop. Cloned from useGenerateSceneJob: this hook owns request-body
5
+ // construction and POSTs it through request<T>() + config URLs; the moment it has a
6
+ // job_id it hands the job to jobTracker.trackJob and stops owning any polling or
7
+ // job state. Polling, the segments×frames-scaled movie poll cap
8
+ // (generateMoviePollCapMs, plumbed via trackJob.segments/nFrames → capFor), the
9
+ // terminal parse, and the centralized library push (segment frames + the movie clip,
10
+ // origin "generate") all live in jobTracker — so a movie kicked off here keeps
11
+ // running (and lands in the library) even after the user switches sub-tabs (which
12
+ // unmounts this station) or reloads the page.
13
+ //
14
+ // The shape differences from useGenerateSceneJob: the request is a
15
+ // GenerateMovieRequest (a goal timeline + director knobs), and `progress` is the
16
+ // NESTED MovieProgress (segment strip + a current-segment view) rather than a flat
17
+ // JobProgress. Outputs are still the full result.outputs array (segment frames,
18
+ // then the movie.mp4 last).
19
+ import { useCallback, useMemo, useRef, useState } from "react";
20
+ import { request, okValue, errorOf, describeAppError } from "../transport/client";
21
+ import { hugpyConfig } from "../config";
22
+ import {
23
+ enqueueResultSchema,
24
+ isMovieProgress,
25
+ type GenerateMovieRequest,
26
+ type JobStatus,
27
+ type MediaRef,
28
+ type MovieProgress,
29
+ type JobProject,
30
+ } from "../video/contract";
31
+ import {
32
+ useJobTracker,
33
+ trackJob,
34
+ resumeJob,
35
+ dismissTerminal,
36
+ isJobActive,
37
+ } from "../video/jobTracker";
38
+
39
+ export interface GenerateMovieJobApi {
40
+ /** Server job id once enqueued, else null. */
41
+ jobId: string | null;
42
+ /** Latest polled status (null = idle or enqueued-but-not-yet-claimed). */
43
+ status: JobStatus;
44
+ /** ALL produced MediaRefs (segment image frames, then the movie clip last). */
45
+ outputs: MediaRef[];
46
+ /** Human message on failure (enqueue error or job error). */
47
+ error: string | null;
48
+ /** Poll cap reached while non-terminal — NOT a failure. */
49
+ capped: boolean;
50
+ /** True while enqueuing or polling a non-terminal job. */
51
+ running: boolean;
52
+ /** Live NESTED movie progress while running (segment strip + current view); null otherwise. */
53
+ progress: MovieProgress | null;
54
+ /** Auto-archive project this movie was saved into (populated on done); null otherwise. */
55
+ project: JobProject | null;
56
+ /** Enqueue ONE generate_movie job and begin polling. Replaces any prior run. */
57
+ run: (req: GenerateMovieRequest) => void;
58
+ /** Re-anchor the poll clock and resume polling a capped job. Clears `capped`. */
59
+ resume: () => void;
60
+ /** Forget this station's finished job (called when the composer is cleared). */
61
+ reset: () => void;
62
+ }
63
+
64
+ const STATION = "generate";
65
+
66
+ /** Flatten the goal-timeline prompts into one provenance summary string. */
67
+ function moviePromptSummary(req: GenerateMovieRequest): string {
68
+ return req.goals
69
+ .map((g) => g.prompt.trim())
70
+ .filter((t) => t !== "")
71
+ .join(" · ")
72
+ .trim();
73
+ }
74
+
75
+ export function useGenerateMovieJob(): GenerateMovieJobApi {
76
+ const records = useJobTracker();
77
+ const [currentJobId, setCurrentJobId] = useState<string | null>(null);
78
+ const [enqueuing, setEnqueuing] = useState<boolean>(false);
79
+ const [enqueueError, setEnqueueError] = useState<string | null>(null);
80
+
81
+ const record = useMemo(() => {
82
+ const mine = records.filter(
83
+ (r) => r.station === STATION && r.kind === "generate_movie",
84
+ );
85
+ if (currentJobId) return mine.find((r) => r.jobId === currentJobId) ?? null;
86
+ if (enqueuing) return null;
87
+ return mine.slice().sort((a, b) => b.enqueuedAt - a.enqueuedAt)[0] ?? null;
88
+ }, [records, currentJobId, enqueuing]);
89
+
90
+ const jobIdRef = useRef<string | null>(record?.jobId ?? null);
91
+ jobIdRef.current = record?.jobId ?? null;
92
+
93
+ const reset = useCallback(() => {
94
+ setCurrentJobId(null);
95
+ setEnqueuing(false);
96
+ setEnqueueError(null);
97
+ dismissTerminal(STATION);
98
+ }, []);
99
+
100
+ const resume = useCallback(() => {
101
+ const id = jobIdRef.current;
102
+ if (id) resumeJob(id);
103
+ }, []);
104
+
105
+ const run = useCallback((req: GenerateMovieRequest) => {
106
+ setCurrentJobId(null);
107
+ setEnqueueError(null);
108
+ setEnqueuing(true);
109
+ const enqueuedAt = Date.now();
110
+
111
+ void request<unknown>(hugpyConfig.generateMovieEnqueueUrl, {
112
+ method: "POST",
113
+ body: JSON.stringify(req),
114
+ headers: { "Content-Type": "application/json" },
115
+ meta: { specKey: STATION, operation: "generate_movie.enqueue" },
116
+ }).then((r) => {
117
+ setEnqueuing(false);
118
+ if (!r.ok) {
119
+ setEnqueueError(describeAppError(errorOf(r)));
120
+ return;
121
+ }
122
+ const parsed = enqueueResultSchema.safeParse(okValue(r));
123
+ if (!parsed.success) {
124
+ setEnqueueError("Malformed enqueue response.");
125
+ return;
126
+ }
127
+ const jobId = parsed.data.job_id;
128
+ // total frame count = max end_frame across the (contiguous) timeline; the
129
+ // poll cap scales off segments × total frames (see generateMoviePollCapMs).
130
+ const framesTotal = req.goals.reduce(
131
+ (mx, g) => Math.max(mx, g.end_frame),
132
+ 0,
133
+ );
134
+ trackJob({
135
+ jobId,
136
+ kind: "generate_movie",
137
+ station: STATION,
138
+ label: "generate movie",
139
+ enqueuedAt,
140
+ segments: req.goals.length,
141
+ nFrames: framesTotal,
142
+ chain: req.chain ?? false,
143
+ prompt: moviePromptSummary(req),
144
+ model: req.model_id,
145
+ });
146
+ setCurrentJobId(jobId);
147
+ });
148
+ }, []);
149
+
150
+ const status: JobStatus = enqueueError ? "failed" : record?.status ?? null;
151
+ const error =
152
+ enqueueError ??
153
+ record?.error ??
154
+ (record?.expired ? "This job is no longer tracked by the server." : null);
155
+
156
+ // The tracker stores progress as a union (flat OR nested); narrow to the movie
157
+ // shape for this hook's consumers (a generate_movie record only ever carries a
158
+ // movie blob, but the narrow keeps the type honest).
159
+ const progress: MovieProgress | null =
160
+ record?.progress && isMovieProgress(record.progress)
161
+ ? record.progress
162
+ : null;
163
+
164
+ return {
165
+ jobId: record?.jobId ?? null,
166
+ status,
167
+ outputs: record?.outputs ?? [],
168
+ error,
169
+ capped: record?.capped ?? false,
170
+ running: enqueuing || (record != null && isJobActive(record)),
171
+ progress,
172
+ project: record?.project ?? null,
173
+ run,
174
+ resume,
175
+ reset,
176
+ };
177
+ }