@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,97 @@
1
+ // The Movie-tab TEMPLATE dropdown source. Fetches the curated movie templates
2
+ // (GET /movie/presets), validates them with the tolerant moviePresetSchema, and
3
+ // exposes the list + loading/error as data (never throws) — the same
4
+ // data-not-throws contract as usePresets.ts, which the Generate station imports
5
+ // alongside it.
6
+ //
7
+ // Two deliberate differences from usePresets:
8
+ // 1. The wire is a BARE ARRAY of presets (not the {presets:[…]} envelope), each
9
+ // item carrying its whole `goals` shot list — see moviePresetSchema.
10
+ // 2. The fetch is GATED behind an `active` flag: the Movie tab is one of three
11
+ // Generate sub-modes, so the list only loads once the movie tab is entered
12
+ // (and stays cached after). Passing active=false keeps it idle.
13
+ import { useCallback, useEffect, useRef, useState } from "react";
14
+ import { z } from "zod";
15
+ import { request, okValue, errorOf, describeAppError } from "../transport/client";
16
+ import { hugpyConfig } from "../config";
17
+ import { moviePresetSchema, type MoviePreset } from "./contract";
18
+
19
+ export interface MoviePresetsState {
20
+ presets: MoviePreset[];
21
+ loading: boolean;
22
+ error: string | null;
23
+ /** Force an immediate re-fetch of the movie-template list. */
24
+ refresh: () => void;
25
+ }
26
+
27
+ // GET /movie/presets returns a {presets:[…]} envelope (matches /video/presets).
28
+ // Tolerant parse of the envelope; each item is a tolerant moviePresetSchema.
29
+ const moviePresetsResponseSchema = z.object({ presets: z.array(moviePresetSchema) });
30
+
31
+ /**
32
+ * Load the curated movie templates. `active` gates the network call: pass
33
+ * `mode === "movie"` so the list only fetches once the Movie tab is entered.
34
+ * While inactive the hook reports loading:false / an empty list (nothing to show).
35
+ */
36
+ export function useMoviePresets(active: boolean): MoviePresetsState {
37
+ const [state, setState] = useState<Omit<MoviePresetsState, "refresh">>({
38
+ presets: [],
39
+ loading: false,
40
+ error: null,
41
+ });
42
+
43
+ const mounted = useRef(true);
44
+ const inFlight = useRef(false);
45
+ const loadedRef = useRef(false);
46
+ const ctrlRef = useRef<AbortController | null>(null);
47
+
48
+ const load = useCallback(async (background: boolean) => {
49
+ if (inFlight.current) return;
50
+ inFlight.current = true;
51
+ if (!background) setState((s) => ({ ...s, loading: true }));
52
+ const signal = ctrlRef.current?.signal;
53
+ try {
54
+ const res = await request<unknown>(hugpyConfig.moviePresetsUrl, {
55
+ signal,
56
+ meta: { specKey: "generate", operation: "movie.presets.list" },
57
+ });
58
+ if (!mounted.current) return;
59
+
60
+ if (!res.ok) {
61
+ // A background refresh that fails must not wipe a working picker.
62
+ if (background) return;
63
+ setState({ presets: [], loading: false, error: describeAppError(errorOf(res)) });
64
+ return;
65
+ }
66
+ const parsed = moviePresetsResponseSchema.safeParse(okValue(res));
67
+ if (!parsed.success) {
68
+ if (background) return;
69
+ setState({ presets: [], loading: false, error: "Malformed movie templates response." });
70
+ return;
71
+ }
72
+ loadedRef.current = true;
73
+ setState({ presets: parsed.data.presets, loading: false, error: null });
74
+ } finally {
75
+ inFlight.current = false;
76
+ }
77
+ }, []);
78
+
79
+ // Load once, the first time the movie tab becomes active. Stays cached after.
80
+ useEffect(() => {
81
+ mounted.current = true;
82
+ if (!active || loadedRef.current) return;
83
+ const ctrl = new AbortController();
84
+ ctrlRef.current = ctrl;
85
+ void load(false);
86
+ return () => {
87
+ mounted.current = false;
88
+ ctrl.abort();
89
+ };
90
+ }, [active, load]);
91
+
92
+ const refresh = useCallback(() => {
93
+ void load(loadedRef.current);
94
+ }, [load]);
95
+
96
+ return { ...state, refresh };
97
+ }
@@ -0,0 +1,292 @@
1
+ // CINEMA SESSIONS — the durable movie sessions on the studio-movies root, plus the
2
+ // two actions on one (pause / resume).
3
+ //
4
+ // WHY A SESSION AND NOT A JOB. A studio movie is ONE bus job that can run for hours,
5
+ // and the job id was the only handle on it: when the job ended — cancelled, reaped,
6
+ // or simply lost with the browser tab — the rendered segments stayed on disk and
7
+ // became unreachable. GET /video/studio/movies lists the DIRS instead, so a session
8
+ // outlives the job that made it; its id is the dir leaf and therefore survives a
9
+ // resume (which mints a NEW job id). See video_routes.py's `studio/movies` block.
10
+ //
11
+ // Cadence + shape follow the studio arm's existing list hooks (useStudioClips'
12
+ // background poll, useMediaJobs' mounted-ref guard): the poll runs ONLY while a
13
+ // consumer asks for it (`active` — the sessions panel is collapsed by default, and a
14
+ // filesystem walk is not worth paying for behind a closed panel), a failed poll keeps
15
+ // the last-good list rather than flashing empty, and a late response never setStates
16
+ // after unmount.
17
+ import { useCallback, useEffect, useRef, useState } from "react";
18
+ import { z } from "zod";
19
+ import {
20
+ hugpyConfig,
21
+ mediaBytesUrl,
22
+ studioMoviePauseUrl,
23
+ studioMovieResumeUrl,
24
+ } from "../config";
25
+ import { request, okValue, errorOf, describeAppError } from "../transport/client";
26
+
27
+ // 6s — the same background cadence useStudioClips polls the clip catalog at. A movie
28
+ // segment takes minutes, so anything faster only costs a filesystem walk.
29
+ const POLL_MS = 6000;
30
+
31
+ // The bus statuses that mean "this session's job is still going to do something" —
32
+ // the client-side mirror of the route's _MOVIE_INFLIGHT_STATES, used to decide
33
+ // whether a row gets a Pause affordance.
34
+ const IN_FLIGHT = new Set(["queued", "claimed", "running", "cancelling"]);
35
+
36
+ // ── wire schemas ────────────────────────────────────────────────────────────
37
+ // Tolerant/passthrough like the movie composer's local schemas: every field beyond
38
+ // the ones we render is optional, so a drifted-forward payload never breaks the
39
+ // parse and one odd row never blanks the list.
40
+ const segmentErrorSchema = z
41
+ .object({
42
+ code: z.string().nullable().optional(),
43
+ message: z.string().nullable().optional(),
44
+ })
45
+ .passthrough();
46
+
47
+ const movieSegmentSchema = z
48
+ .object({
49
+ index: z.number(),
50
+ segment_id: z.string().nullable().optional(),
51
+ prompt: z.string().nullable().optional(),
52
+ status: z.string().nullable().optional(),
53
+ resumed: z.boolean().nullable().optional(),
54
+ frames: z.number().nullable().optional(),
55
+ duration_s: z.number().nullable().optional(),
56
+ error: segmentErrorSchema.nullable().optional(),
57
+ /** Honest "can I watch this right now" — false for a failed segment AND for one
58
+ * whose record exists but whose bytes are gone (the url is then omitted). */
59
+ clip_available: z.boolean().nullable().optional(),
60
+ media: z.string().nullable().optional(),
61
+ })
62
+ .passthrough();
63
+
64
+ export type MovieSegment = z.infer<typeof movieSegmentSchema>;
65
+
66
+ const movieSessionSchema = z
67
+ .object({
68
+ movie_id: z.string(),
69
+ job_id: z.string().nullable().optional(),
70
+ job_status: z.string().nullable().optional(),
71
+ title: z.string().nullable().optional(),
72
+ project: z.string().nullable().optional(),
73
+ status: z.string().nullable().optional(),
74
+ segments_completed: z.number().nullable().optional(),
75
+ segments_total: z.number().nullable().optional(),
76
+ segments: z.array(movieSegmentSchema).optional(),
77
+ /** Server-computed: there is a persisted spec to re-enqueue AND the work is
78
+ * neither finished nor already in flight. The Resume affordance keys on this
79
+ * rather than re-deriving the rule per client. */
80
+ resumable: z.boolean().nullable().optional(),
81
+ updated: z.number().nullable().optional(),
82
+ width: z.number().nullable().optional(),
83
+ height: z.number().nullable().optional(),
84
+ fps: z.number().nullable().optional(),
85
+ id_lock: z.boolean().nullable().optional(),
86
+ /** The assembled movie.mp4, when one was stitched AND still exists on disk. */
87
+ movie: z.string().nullable().optional(),
88
+ })
89
+ .passthrough();
90
+
91
+ export type MovieSession = z.infer<typeof movieSessionSchema>;
92
+
93
+ const moviesResponseSchema = z
94
+ .object({ movies: z.array(movieSessionSchema) })
95
+ .passthrough();
96
+
97
+ // ── derived reads (one definition each, shared by the panel) ────────────────
98
+
99
+ /**
100
+ * A playable `<video src>` for a wire media url (a segment clip or the assembled
101
+ * movie), or null when there is nothing to serve.
102
+ *
103
+ * The route hands over a ready `/video/media?handle=<path>` url, but every other
104
+ * element-src in this console goes through `mediaBytesUrl` — which is where the
105
+ * share-session credential is stamped into the query (an element load carries no
106
+ * header) and where canned-demo handles pass through. So the HANDLE is unwrapped
107
+ * and re-issued through that one seam rather than the url being used raw, and a
108
+ * shape this parser doesn't recognise degrades to "not playable" instead of a
109
+ * link that 404s.
110
+ */
111
+ export function movieMediaSrc(wire: string | null | undefined): string | null {
112
+ if (!wire) return null;
113
+ const q = wire.indexOf("?");
114
+ if (q < 0) return null;
115
+ const handle = new URLSearchParams(wire.slice(q + 1)).get("handle");
116
+ return handle ? mediaBytesUrl(handle) : null;
117
+ }
118
+
119
+ /** The segment's clip src — null unless the route says its bytes are on disk NOW. */
120
+ export function segmentClipSrc(seg: MovieSegment): string | null {
121
+ if (!seg.clip_available) return null;
122
+ return movieMediaSrc(seg.media);
123
+ }
124
+
125
+ /** True when this session's CURRENT job is still on the bus — the Pause affordance. */
126
+ export function isSessionLive(s: MovieSession): boolean {
127
+ return IN_FLIGHT.has(s.job_status ?? "");
128
+ }
129
+
130
+ /**
131
+ * WHY this session cannot be resumed right now, or null when it can. The rule is the
132
+ * server's (`resumable`); this only names the reason, so a disabled button always says
133
+ * what it is waiting for instead of being silently dead.
134
+ */
135
+ export function resumeBlockedReason(s: MovieSession): string | null {
136
+ if (s.resumable) return null;
137
+ if (s.status === "done") return "This movie finished — there is nothing left to render.";
138
+ if (isSessionLive(s) || s.status === "running") {
139
+ return "This movie is already running — pause it first.";
140
+ }
141
+ return (
142
+ "This movie has no persisted spec.json, so it cannot be re-enqueued (it predates " +
143
+ "resumable sessions); its rendered segments are still listed and playable."
144
+ );
145
+ }
146
+
147
+ export interface MovieSessionsState {
148
+ sessions: MovieSession[];
149
+ loading: boolean;
150
+ /** List-level failure (the poll keeps the last-good list; this is the foreground read). */
151
+ error: string | null;
152
+ /** Force an immediate reload (fired right after a pause/resume lands). */
153
+ reload: () => void;
154
+ /** Movie ids with a pause/resume in flight — the per-row button's busy state. */
155
+ busy: Set<string>;
156
+ /** Per-movie action failure, kept VERBATIM from the route (the 409 bodies say why). */
157
+ actionError: Record<string, string>;
158
+ pause: (movieId: string) => void;
159
+ resume: (movieId: string) => void;
160
+ }
161
+
162
+ /**
163
+ * The Cinema session list + its two actions. `active` gates the poll: pass the
164
+ * panel's own visibility so a collapsed panel costs nothing (the route walks the
165
+ * movies root on every call).
166
+ */
167
+ export function useMovieSessions(active: boolean): MovieSessionsState {
168
+ const [sessions, setSessions] = useState<MovieSession[]>([]);
169
+ // Starts TRUE so a freshly-opened panel reads "reading the movies root…" instead of
170
+ // flashing "no sessions" for one round trip — to an operator looking for a render
171
+ // they think they lost, those two say opposite things.
172
+ const [loading, setLoading] = useState(true);
173
+ const [error, setError] = useState<string | null>(null);
174
+ const [busy, setBusy] = useState<Set<string>>(new Set());
175
+ const [actionError, setActionError] = useState<Record<string, string>>({});
176
+
177
+ const mounted = useRef(true);
178
+ const inFlight = useRef(false);
179
+ // Only the NEWEST read may write the list. Without this a poll issued BEFORE a
180
+ // pause/resume can land after it and restore the pre-action rows, making the action
181
+ // look like it did nothing for a whole tick.
182
+ const seq = useRef(0);
183
+ // First load of a newly-opened panel shows a spinner; every poll after it is
184
+ // silent, so an open panel never flickers between ticks.
185
+ const loaded = useRef(false);
186
+
187
+ useEffect(() => {
188
+ mounted.current = true;
189
+ return () => {
190
+ mounted.current = false;
191
+ };
192
+ }, []);
193
+
194
+ // `force` is for the reload that follows a pause/resume: it must not be swallowed by
195
+ // the in-flight guard that keeps the 6s poll from stacking up on a slow route.
196
+ const load = useCallback(async (force = false) => {
197
+ if (inFlight.current && !force) return;
198
+ inFlight.current = true;
199
+ const mine = (seq.current += 1);
200
+ if (!loaded.current) setLoading(true);
201
+ try {
202
+ const res = await request<unknown>(hugpyConfig.studioMoviesUrl, {
203
+ meta: { specKey: "studio", operation: "studio.movies.list" },
204
+ });
205
+ if (!mounted.current || mine !== seq.current) return;
206
+ if (!res.ok) {
207
+ // A failed poll must not wipe a working list — only the FIRST read (nothing
208
+ // on screen yet) is allowed to surface as an error.
209
+ if (!loaded.current) setError(describeAppError(errorOf(res)));
210
+ return;
211
+ }
212
+ const parsed = moviesResponseSchema.safeParse(okValue(res));
213
+ if (!parsed.success) {
214
+ if (!loaded.current) setError("Malformed movie-sessions response.");
215
+ return;
216
+ }
217
+ loaded.current = true;
218
+ setError(null);
219
+ setSessions(parsed.data.movies);
220
+ } finally {
221
+ inFlight.current = false;
222
+ if (mounted.current) setLoading(false);
223
+ }
224
+ }, []);
225
+
226
+ useEffect(() => {
227
+ if (!active) return;
228
+ void load();
229
+ const id = window.setInterval(() => void load(), POLL_MS);
230
+ return () => window.clearInterval(id);
231
+ }, [active, load]);
232
+
233
+ // Pause and resume differ only in their url and their telemetry op, so they share
234
+ // ONE runner: mark the row busy, clear its last error, POST, keep the route's own
235
+ // message verbatim on failure (the 409s — "no persisted spec.json", "already
236
+ // running" — ARE the explanation), then reload so the row's new status is the
237
+ // server's answer rather than an optimistic guess.
238
+ const act = useCallback(
239
+ async (movieId: string, url: string, operation: string) => {
240
+ setBusy((prev) => new Set(prev).add(movieId));
241
+ setActionError((prev) => {
242
+ const next = { ...prev };
243
+ delete next[movieId];
244
+ return next;
245
+ });
246
+ try {
247
+ const res = await request<unknown>(url, {
248
+ method: "POST",
249
+ meta: { specKey: "studio", operation },
250
+ });
251
+ if (!mounted.current) return;
252
+ if (!res.ok) {
253
+ setActionError((prev) => ({
254
+ ...prev,
255
+ [movieId]: describeAppError(errorOf(res)),
256
+ }));
257
+ return;
258
+ }
259
+ await load(true);
260
+ } finally {
261
+ if (mounted.current) {
262
+ setBusy((prev) => {
263
+ const next = new Set(prev);
264
+ next.delete(movieId);
265
+ return next;
266
+ });
267
+ }
268
+ }
269
+ },
270
+ [load],
271
+ );
272
+
273
+ const pause = useCallback(
274
+ (movieId: string) => {
275
+ void act(movieId, studioMoviePauseUrl(movieId), "studio.movie.pause");
276
+ },
277
+ [act],
278
+ );
279
+
280
+ const resume = useCallback(
281
+ (movieId: string) => {
282
+ void act(movieId, studioMovieResumeUrl(movieId), "studio.movie.resume");
283
+ },
284
+ [act],
285
+ );
286
+
287
+ const reload = useCallback(() => {
288
+ void load();
289
+ }, [load]);
290
+
291
+ return { sessions, loading, error, reload, busy, actionError, pause, resume };
292
+ }
@@ -0,0 +1,167 @@
1
+ // The Generate-station preset dropdown source. Fetches the curated "ideal default
2
+ // loads" (GET /video/presets), validates them with a tolerant passthrough schema,
3
+ // and exposes the options + loading/error as data (never throws) — the exact same
4
+ // data-not-throws contract as useModels.ts, which the station imports alongside it.
5
+ //
6
+ // Presets are curated server-side and rarely change mid-session (unlike the model
7
+ // registry, which grows as ComfyUI checkpoints get adopted), so this hook does a
8
+ // single foreground load and a manual `refresh()` — it deliberately skips the
9
+ // focus/visibility/interval revalidation machinery useModels.ts needs.
10
+ import { useCallback, useEffect, useRef, useState } from "react";
11
+ import { z } from "zod";
12
+ import { request, okValue, errorOf, describeAppError } from "../transport/client";
13
+ import { hugpyConfig } from "../config";
14
+
15
+ // The knob defaults a preset prefills onto the Generate station. All optional +
16
+ // nullable: a preset may omit any field (the station keeps its current value), and
17
+ // the wire may carry extras we ignore (passthrough). Shared by the apply echo.
18
+ export interface PresetDefaults {
19
+ strength?: number | null;
20
+ steps?: number | null;
21
+ guidance?: number | null;
22
+ width?: number | null;
23
+ height?: number | null;
24
+ n_frames?: number | null;
25
+ fps?: number | null;
26
+ negative?: string | null;
27
+ }
28
+
29
+ export interface Preset {
30
+ /** Opaque id — the path segment of the apply POST. */
31
+ id: string;
32
+ /** Human label for the dropdown + status line. */
33
+ name: string;
34
+ /** One-line description surfaced under the picker when selected. */
35
+ description?: string | null;
36
+ /** Generation sub-mode this preset targets ("text-to-image" | "edit-chain" | "img2img" | …). */
37
+ mode: string;
38
+ /** The model the station selects + the apply POST pre-warms. */
39
+ model_key: string;
40
+ /** Knob values to prefill. */
41
+ defaults: PresetDefaults;
42
+ /** Advisory placement hint (e.g. "gpu"). */
43
+ recommended?: string | null;
44
+ }
45
+
46
+ export interface PresetsState {
47
+ presets: Preset[];
48
+ loading: boolean;
49
+ error: string | null;
50
+ /** Force an immediate re-fetch of the preset list. */
51
+ refresh: () => void;
52
+ }
53
+
54
+ // Tolerant schemas — passthrough keeps unknown fields; we only assert what we read.
55
+ export const presetDefaultsSchema = z
56
+ .object({
57
+ strength: z.number().nullable().optional(),
58
+ steps: z.number().nullable().optional(),
59
+ guidance: z.number().nullable().optional(),
60
+ width: z.number().nullable().optional(),
61
+ height: z.number().nullable().optional(),
62
+ n_frames: z.number().nullable().optional(),
63
+ fps: z.number().nullable().optional(),
64
+ negative: z.string().nullable().optional(),
65
+ })
66
+ .passthrough();
67
+
68
+ export const presetSchema = z
69
+ .object({
70
+ id: z.string(),
71
+ name: z.string(),
72
+ description: z.string().nullable().optional(),
73
+ mode: z.string(),
74
+ model_key: z.string(),
75
+ defaults: presetDefaultsSchema,
76
+ recommended: z.string().nullable().optional(),
77
+ })
78
+ .passthrough();
79
+
80
+ const presetsResponseSchema = z
81
+ .object({ presets: z.array(presetSchema) })
82
+ .passthrough();
83
+
84
+ // The per-id apply response — a discriminated pair sharing `ok`. On success it
85
+ // echoes the (possibly server-adjusted) model_key/mode/defaults the station honors
86
+ // as authoritative; on failure it carries {error:{code,message}}. Tolerant so a
87
+ // partial worker block or extra fields never fail the parse.
88
+ export const presetApplyResponseSchema = z
89
+ .object({
90
+ ok: z.boolean(),
91
+ worker: z
92
+ .object({ name: z.string().nullable().optional(), id: z.string().nullable().optional() })
93
+ .passthrough()
94
+ .nullable()
95
+ .optional(),
96
+ model_key: z.string().nullable().optional(),
97
+ mode: z.string().nullable().optional(),
98
+ defaults: presetDefaultsSchema.nullable().optional(),
99
+ warming: z.boolean().nullable().optional(),
100
+ error: z
101
+ .object({
102
+ code: z.string().nullable().optional(),
103
+ message: z.string().nullable().optional(),
104
+ })
105
+ .passthrough()
106
+ .nullable()
107
+ .optional(),
108
+ })
109
+ .passthrough();
110
+
111
+ export function usePresets(): PresetsState {
112
+ const [state, setState] = useState<Omit<PresetsState, "refresh">>({
113
+ presets: [],
114
+ loading: true,
115
+ error: null,
116
+ });
117
+
118
+ const mounted = useRef(true);
119
+ const inFlight = useRef(false);
120
+ const ctrlRef = useRef<AbortController | null>(null);
121
+
122
+ const load = useCallback(async (background: boolean) => {
123
+ if (inFlight.current) return;
124
+ inFlight.current = true;
125
+ const signal = ctrlRef.current?.signal;
126
+ try {
127
+ const res = await request<unknown>(hugpyConfig.presetsUrl, {
128
+ signal,
129
+ meta: { specKey: "generate", operation: "presets.list" },
130
+ });
131
+ if (!mounted.current) return;
132
+
133
+ if (!res.ok) {
134
+ // A background refresh that fails must not wipe a working picker.
135
+ if (background) return;
136
+ setState({ presets: [], loading: false, error: describeAppError(errorOf(res)) });
137
+ return;
138
+ }
139
+ const parsed = presetsResponseSchema.safeParse(okValue(res));
140
+ if (!parsed.success) {
141
+ if (background) return;
142
+ setState({ presets: [], loading: false, error: "Malformed presets response." });
143
+ return;
144
+ }
145
+ setState({ presets: parsed.data.presets, loading: false, error: null });
146
+ } finally {
147
+ inFlight.current = false;
148
+ }
149
+ }, []);
150
+
151
+ useEffect(() => {
152
+ mounted.current = true;
153
+ const ctrl = new AbortController();
154
+ ctrlRef.current = ctrl;
155
+ void load(false);
156
+ return () => {
157
+ mounted.current = false;
158
+ ctrl.abort();
159
+ };
160
+ }, [load]);
161
+
162
+ const refresh = useCallback(() => {
163
+ void load(true);
164
+ }, [load]);
165
+
166
+ return { ...state, refresh };
167
+ }
@@ -0,0 +1,87 @@
1
+ // The Settings-tab "Project" combobox source. Fetches the distinct known project
2
+ // names (GET /video/projects → { projects: [name, …] }) so the field can offer
3
+ // existing names as a datalist while still letting the user FREE-TYPE a brand-new
4
+ // one (choose-or-type). Data-not-throws, like useStudioPresets (from which the fetch
5
+ // idiom is cloned): a failed/absent list just yields [] — the field still works as a
6
+ // plain free-text input (empty = auto-named, the default). `refresh` re-pulls so a
7
+ // name coined on this enqueue can appear in the list next time.
8
+ import { useCallback, useEffect, useRef, useState } from "react";
9
+ import { z } from "zod";
10
+ import { request, okValue, errorOf, describeAppError } from "../transport/client";
11
+ import { hugpyConfig } from "../config";
12
+
13
+ const projectsResponseSchema = z.object({ projects: z.array(z.string()) });
14
+
15
+ export interface ProjectsState {
16
+ projects: string[];
17
+ loading: boolean;
18
+ error: string | null;
19
+ /** Force an immediate re-fetch of the project-name list. */
20
+ refresh: () => void;
21
+ }
22
+
23
+ /**
24
+ * Load the distinct known project names. `active` gates the network call; while
25
+ * inactive the hook reports loading:false / an empty list. Stays cached after the
26
+ * first successful pull; `refresh` re-pulls in the background.
27
+ */
28
+ export function useProjects(active: boolean): ProjectsState {
29
+ const [state, setState] = useState<Omit<ProjectsState, "refresh">>({
30
+ projects: [],
31
+ loading: false,
32
+ error: null,
33
+ });
34
+
35
+ const mounted = useRef(true);
36
+ const inFlight = useRef(false);
37
+ const loadedRef = useRef(false);
38
+ const ctrlRef = useRef<AbortController | null>(null);
39
+
40
+ const load = useCallback(async (background: boolean) => {
41
+ if (inFlight.current) return;
42
+ inFlight.current = true;
43
+ if (!background) setState((s) => ({ ...s, loading: true }));
44
+ const signal = ctrlRef.current?.signal;
45
+ try {
46
+ const res = await request<unknown>(hugpyConfig.projectsUrl, {
47
+ signal,
48
+ meta: { specKey: "studio", operation: "video.projects.list" },
49
+ });
50
+ if (!mounted.current) return;
51
+ if (!res.ok) {
52
+ // A background refresh that fails must not wipe a working list.
53
+ if (background) return;
54
+ setState({ projects: [], loading: false, error: describeAppError(errorOf(res)) });
55
+ return;
56
+ }
57
+ const parsed = projectsResponseSchema.safeParse(okValue(res));
58
+ if (!parsed.success) {
59
+ if (background) return;
60
+ setState({ projects: [], loading: false, error: "Malformed projects response." });
61
+ return;
62
+ }
63
+ loadedRef.current = true;
64
+ setState({ projects: parsed.data.projects, loading: false, error: null });
65
+ } finally {
66
+ inFlight.current = false;
67
+ }
68
+ }, []);
69
+
70
+ useEffect(() => {
71
+ mounted.current = true;
72
+ if (!active || loadedRef.current) return;
73
+ const ctrl = new AbortController();
74
+ ctrlRef.current = ctrl;
75
+ void load(false);
76
+ return () => {
77
+ mounted.current = false;
78
+ ctrl.abort();
79
+ };
80
+ }, [active, load]);
81
+
82
+ const refresh = useCallback(() => {
83
+ void load(loadedRef.current);
84
+ }, [load]);
85
+
86
+ return { ...state, refresh };
87
+ }