@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,394 @@
1
+ // Shared LLM PROMPT-ASSIST hook — the Enhance / Generate transport + busy/error
2
+ // model, extracted from GenerateStation so the Generate, Studio Clip, and Studio
3
+ // Movie composers all POST the SAME promptAssistUrl with ONE in-flight call at a
4
+ // time and identical error semantics.
5
+ //
6
+ // Presentational-by-callback, like GoalComposer: this hook owns NO editor state.
7
+ // Each host passes the current draft when it fires a call and an `apply` callback
8
+ // that drops the returned prompt wherever that host keeps its text (replace the
9
+ // primary text part / set the prompt string / patch a goal row). A failed call
10
+ // NEVER reaches `apply`, so a bad response can't clobber the existing prompt — it
11
+ // surfaces on `assistError` instead. The paired <PromptAssistButtons> renders the
12
+ // button cluster; keeping the two beside each other keeps adoption a small,
13
+ // non-destabilizing flip per composer.
14
+ import { useCallback, useEffect, useState } from "react";
15
+ import { request, okValue, errorOf, describeAppError } from "../transport/client";
16
+ import { hugpyConfig } from "../config";
17
+ import {
18
+ readIntentResult,
19
+ readSpreadResult,
20
+ type AssistContextWire,
21
+ type IntentResult,
22
+ type SpreadRequestWire,
23
+ type SpreadResult,
24
+ } from "./spreadAssist";
25
+
26
+ /** The two assist actions: "detail" = Enhance (enrich a draft), "generate" = write fresh. */
27
+ export type AssistMode = "detail" | "generate";
28
+
29
+ /**
30
+ * Every mode that can hold the ONE in-flight assist slot. The two whole-request
31
+ * modes added by STUDIO-SPREAD-SPEC ("spread" = one call rewriting N selected rows,
32
+ * "negative" = an exclusion list rather than prose) are NOT `AssistMode`, because
33
+ * they take no `draft` and do not return a prompt for a single field — but they DO
34
+ * share the concurrency gate and the error model, so they share the busy value.
35
+ * Widening only the BUSY type keeps `runAssist`'s signature honest.
36
+ */
37
+ export type AssistBusyMode = AssistMode | "spread" | "negative";
38
+
39
+ /** The active sub-mode, sent as `context.kind` so video modes get motion/camera phrasing. */
40
+ export type AssistKind = "image" | "scene" | "movie";
41
+
42
+ /** The `specKey` a host's telemetry rides under (matches the host's other calls). */
43
+ type SpecKey = "generate" | "studio";
44
+
45
+ export interface UsePromptAssistOptions {
46
+ /** Sub-mode sent as `context.kind` (image | scene | movie). */
47
+ kind: AssistKind;
48
+ /** Telemetry spec key for the request meta (the host's own key). */
49
+ specKey: SpecKey;
50
+ }
51
+
52
+ /** One selectable text generator, as offered by the backend's discovery route. */
53
+ export interface AssistModel {
54
+ /** Catalog key sent back as `body.model`. */
55
+ model: string;
56
+ /** Seated on a worker right now (answers in ~1s) vs present but cold (first call loads it). */
57
+ serving: boolean;
58
+ framework?: string | null;
59
+ /** True for the fleet default — used when the user has never chosen. */
60
+ default?: boolean;
61
+ }
62
+
63
+ /**
64
+ * The per-call typed extras (SPEC §1c). Purely ADDITIVE: a host that passes nothing
65
+ * sends the byte-identical body it sent before, which is what keeps GenerateStation
66
+ * and StudioGenerateTab compiling and behaving unchanged.
67
+ */
68
+ export interface AssistExtras {
69
+ /**
70
+ * Typed row/identity state merged into `context` beside `kind`. This is the
71
+ * carrier for structured state — `hint` stays free-form user text (§1c ruling).
72
+ */
73
+ context?: Omit<AssistContextWire, "kind">;
74
+ /**
75
+ * Per-CALL model override. When set, this generator writes THIS call's prompt
76
+ * regardless of the hook-wide `assistModel` — the carrier for per-component
77
+ * assist-model selection (each prompt component can pick its own). Omitted by
78
+ * hosts that pass nothing, which then use the hook `assistModel` / fleet default.
79
+ */
80
+ model?: string | null;
81
+ }
82
+
83
+ export interface PromptAssistApi {
84
+ /** The ACTIVE mode while a call is in flight, or null when idle. Blocks concurrency + drives labels. */
85
+ assistBusy: AssistBusyMode | null;
86
+ /** The dismissible inline notice; a failed call sets it and leaves the prompt intact. */
87
+ assistError: string | null;
88
+ /** Clear the inline error notice. */
89
+ dismissError: () => void;
90
+ /**
91
+ * Fire an assist call. `draft` is the host's current prompt text (Enhance requires it;
92
+ * Generate sends it as a loose theme). On success the returned prompt is handed to
93
+ * `apply`; on ANY failure `apply` is never called and `assistError` is set instead.
94
+ */
95
+ runAssist: (
96
+ mode: AssistMode,
97
+ draft: string,
98
+ apply: (prompt: string) => void,
99
+ extra?: AssistExtras,
100
+ ) => Promise<void>;
101
+ /**
102
+ * Generate a NEGATIVE prompt (mode "negative", §1b) — an artifact/quality exclusion
103
+ * list, not prose. `subject` is what is being negated (the row's own prompt); a
104
+ * failure never reaches `apply`, exactly like runAssist.
105
+ */
106
+ runNegative: (
107
+ opts: { subject?: string; draft?: string; extra?: AssistExtras },
108
+ apply: (negative: string) => void,
109
+ ) => Promise<void>;
110
+ /**
111
+ * Run ONE coherent spread across the selected rows (mode "spread", §1a). Resolves
112
+ * to the parsed result, or null when the call failed (the reason is on
113
+ * `assistError`). NEVER N sequential per-row calls — that is the incoherence this
114
+ * whole feature exists to delete.
115
+ */
116
+ runSpread: (body: SpreadRequestWire) => Promise<SpreadResult | null>;
117
+ /**
118
+ * Classify a field's text (§1d). Deliberately OUTSIDE the busy gate: this is called
119
+ * on blur, must not be blocked by an in-flight generation, and must never surface as
120
+ * an error — any failure reads as a degraded `ambiguous`, i.e. "show both actions".
121
+ */
122
+ classifyIntent: (text: string, scope?: "segment" | "movie") => Promise<IntentResult>;
123
+ /** The text generators this fleet can actually run (empty until discovery returns). */
124
+ assistModels: AssistModel[];
125
+ /** The selected generator, or null to let the backend use the fleet default. */
126
+ assistModel: string | null;
127
+ /** Choose a generator; persisted so the choice survives a reload. */
128
+ setAssistModel: (model: string | null) => void;
129
+ }
130
+
131
+ // The user's pick is a PREFERENCE, not app state: persist it in localStorage so it
132
+ // survives a reload, and share it across all three composers (Generate, Studio Clip,
133
+ // Studio Movie) — they each mount their own hook, so a module-level key is what makes
134
+ // the choice feel like one setting rather than three.
135
+ const ASSIST_MODEL_KEY = "hugpy.promptAssist.model";
136
+
137
+ function readStoredModel(): string | null {
138
+ try {
139
+ return window.localStorage.getItem(ASSIST_MODEL_KEY) || null;
140
+ } catch {
141
+ return null; // private mode / storage disabled — not fatal
142
+ }
143
+ }
144
+
145
+ // Defensively pull `.prompt` (a non-empty string) out of the prompt-assist 200 body
146
+ // ({ prompt, model, kind }). Returns null for any malformed/empty shape so a bad
147
+ // response surfaces as a notice instead of clobbering the prompt with junk. (Lifted
148
+ // verbatim from GenerateStation's local helper so every host parses identically.)
149
+ function readAssistPrompt(value: unknown): string | null {
150
+ if (value != null && typeof value === "object" && "prompt" in value) {
151
+ const p = (value as { prompt?: unknown }).prompt;
152
+ if (typeof p === "string" && p.trim() !== "") return p;
153
+ }
154
+ return null;
155
+ }
156
+
157
+ export function usePromptAssist({ kind, specKey }: UsePromptAssistOptions): PromptAssistApi {
158
+ // One in-flight call at a time: `assistBusy` holds the ACTIVE mode or null when idle —
159
+ // it both blocks concurrent calls and drives which button shows its loading label.
160
+ const [assistBusy, setAssistBusy] = useState<AssistBusyMode | null>(null);
161
+ const [assistError, setAssistError] = useState<string | null>(null);
162
+ const [assistModels, setAssistModels] = useState<AssistModel[]>([]);
163
+ const [assistModel, setAssistModelState] = useState<string | null>(readStoredModel);
164
+
165
+ // Discover the offerable generators once per mount. A failure is SILENT on purpose:
166
+ // the picker simply stays empty and assist keeps working on the fleet default, so a
167
+ // discovery hiccup can never block Enhance/Generate.
168
+ useEffect(() => {
169
+ let alive = true;
170
+ (async () => {
171
+ const res = await request<unknown>(hugpyConfig.promptAssistModelsUrl, {
172
+ method: "GET",
173
+ timeoutMs: 15_000,
174
+ meta: { specKey, operation: "prompt.assist.models" },
175
+ });
176
+ if (!alive || !res.ok) return;
177
+ const value = okValue(res);
178
+ const rows =
179
+ value != null && typeof value === "object" && Array.isArray((value as { models?: unknown }).models)
180
+ ? ((value as { models: AssistModel[] }).models)
181
+ : [];
182
+ setAssistModels(rows);
183
+ // Drop a stored pick the fleet no longer offers (model retired, worker gone) —
184
+ // otherwise the UI shows a selection that would fail on use.
185
+ setAssistModelState((current) =>
186
+ current && !rows.some((r) => r.model === current) ? null : current,
187
+ );
188
+ })();
189
+ return () => {
190
+ alive = false;
191
+ };
192
+ }, [specKey]);
193
+
194
+ const setAssistModel = useCallback((model: string | null) => {
195
+ setAssistModelState(model);
196
+ try {
197
+ if (model) window.localStorage.setItem(ASSIST_MODEL_KEY, model);
198
+ else window.localStorage.removeItem(ASSIST_MODEL_KEY);
199
+ } catch {
200
+ /* storage disabled — the choice still applies for this session */
201
+ }
202
+ }, []);
203
+
204
+ const dismissError = useCallback(() => setAssistError(null), []);
205
+
206
+ const runAssist = useCallback(
207
+ async (
208
+ mode: AssistMode,
209
+ draft: string,
210
+ apply: (prompt: string) => void,
211
+ extra?: AssistExtras,
212
+ ): Promise<void> => {
213
+ if (assistBusy) return; // block concurrent calls
214
+ const trimmed = draft.trim();
215
+ if (mode === "detail" && trimmed === "") return; // Enhance needs a draft
216
+ setAssistError(null);
217
+ setAssistBusy(mode);
218
+
219
+ // `model` is sent ONLY when the user has picked one; omitting it lets the backend
220
+ // apply the fleet default (flux2-klein-9b-uncensored-text-encoder, served from
221
+ // computron). The old comment here claimed "the backend picks the best resolvable
222
+ // chat model" — it did not, it used one hardcoded key; the picker plus a resolved
223
+ // default is what finally made that sentence true. The draft is omitted when blank
224
+ // (Generate from a truly empty box) and context.kind carries the active sub-mode so
225
+ // video modes get motion/camera phrasing.
226
+ //
227
+ // Thinking is suppressed server-side for EVERY generator (the assist route appends
228
+ // a no-think directive and strips any <think>...</think> that survives), so picking
229
+ // a reasoning model here cannot dump a monologue into the prompt box.
230
+ //
231
+ // TYPED CONTEXT (§1c): `extra.context` carries the STRUCTURED row state — this
232
+ // segment, its neighbours, the locked identity — beside `kind`. The backend
233
+ // renders joint modes into plain sentences and attaches the identity's
234
+ // do-not-invent list, which is what stopped the generator making up wardrobes
235
+ // for a bare character name. Omitted entirely by hosts that pass nothing, so
236
+ // their request body is unchanged.
237
+ const body: {
238
+ mode: AssistMode;
239
+ draft?: string;
240
+ model?: string;
241
+ context: AssistContextWire;
242
+ } = {
243
+ mode,
244
+ ...(trimmed !== "" ? { draft: trimmed } : {}),
245
+ ...((extra?.model ?? assistModel) ? { model: extra?.model ?? assistModel ?? undefined } : {}),
246
+ context: { kind, ...(extra?.context ?? {}) },
247
+ };
248
+
249
+ const res = await request<unknown>(hugpyConfig.promptAssistUrl, {
250
+ method: "POST",
251
+ headers: { "Content-Type": "application/json" },
252
+ body: JSON.stringify(body),
253
+ timeoutMs: 90_000, // a few seconds typically; generous for an on-demand worker
254
+ meta: { specKey, operation: `prompt.assist.${mode}` },
255
+ });
256
+ if (!res.ok) {
257
+ setAssistError(describeAppError(errorOf(res)));
258
+ setAssistBusy(null);
259
+ return;
260
+ }
261
+ const prompt = readAssistPrompt(okValue(res));
262
+ if (prompt == null) {
263
+ setAssistError("The assistant returned an empty prompt — try again.");
264
+ setAssistBusy(null);
265
+ return;
266
+ }
267
+ apply(prompt);
268
+ setAssistBusy(null);
269
+ },
270
+ [assistBusy, kind, specKey, assistModel],
271
+ );
272
+
273
+ // ── mode "negative" (§1b) ────────────────────────────────────────────────
274
+ // A negative prompt is an ARTIFACT/QUALITY EXCLUSION LIST, not prose — reusing the
275
+ // scene system prompt returns a poem, which is why this is its own mode rather than
276
+ // a `detail` call with different words. `prompt` and `negative` carry the same text
277
+ // on the wire (no existing key moved); we read `prompt` via the shared reader.
278
+ const runNegative = useCallback(
279
+ async (
280
+ opts: { subject?: string; draft?: string; extra?: AssistExtras },
281
+ apply: (negative: string) => void,
282
+ ): Promise<void> => {
283
+ if (assistBusy) return;
284
+ setAssistError(null);
285
+ setAssistBusy("negative");
286
+ const body: Record<string, unknown> = {
287
+ mode: "negative",
288
+ context: { kind, ...(opts.extra?.context ?? {}) },
289
+ };
290
+ if (opts.subject && opts.subject.trim()) body.subject = opts.subject.trim();
291
+ if (opts.draft && opts.draft.trim()) body.draft = opts.draft.trim();
292
+ const negModel = opts.extra?.model ?? assistModel;
293
+ if (negModel) body.model = negModel;
294
+
295
+ const res = await request<unknown>(hugpyConfig.promptAssistUrl, {
296
+ method: "POST",
297
+ headers: { "Content-Type": "application/json" },
298
+ body: JSON.stringify(body),
299
+ timeoutMs: 90_000,
300
+ meta: { specKey, operation: "prompt.assist.negative" },
301
+ });
302
+ if (!res.ok) {
303
+ setAssistError(describeAppError(errorOf(res)));
304
+ setAssistBusy(null);
305
+ return;
306
+ }
307
+ const text = readAssistPrompt(okValue(res));
308
+ if (text == null) {
309
+ setAssistError("The assistant returned an empty negative — try again.");
310
+ setAssistBusy(null);
311
+ return;
312
+ }
313
+ apply(text);
314
+ setAssistBusy(null);
315
+ },
316
+ [assistBusy, kind, specKey, assistModel],
317
+ );
318
+
319
+ // ── mode "spread" (§1a) ──────────────────────────────────────────────────
320
+ // ONE call for the whole selection. Returns the parsed result to the caller instead
321
+ // of applying anything itself: only the host knows which editor rows the returned
322
+ // segment_ids map to, and the "apply ONLY to rows that came back" rule is the whole
323
+ // safety property. A 502 carries the raw reply — surfaced honestly by the shared
324
+ // error mapper, never replaced with a fabricated segment.
325
+ const runSpread = useCallback(
326
+ async (body: SpreadRequestWire): Promise<SpreadResult | null> => {
327
+ if (assistBusy) return null;
328
+ setAssistError(null);
329
+ setAssistBusy("spread");
330
+ const payload = { ...body, ...(assistModel ? { model: assistModel } : {}) };
331
+ const res = await request<unknown>(hugpyConfig.promptAssistUrl, {
332
+ method: "POST",
333
+ headers: { "Content-Type": "application/json" },
334
+ body: JSON.stringify(payload),
335
+ // A spread writes N paragraphs in one generation, so it is the longest assist
336
+ // call there is — and on a cold generator it pays a model load first.
337
+ timeoutMs: 180_000,
338
+ meta: { specKey, operation: "prompt.assist.spread" },
339
+ });
340
+ if (!res.ok) {
341
+ setAssistError(describeAppError(errorOf(res)));
342
+ setAssistBusy(null);
343
+ return null;
344
+ }
345
+ const parsed = readSpreadResult(okValue(res));
346
+ setAssistBusy(null);
347
+ if (parsed == null) {
348
+ setAssistError("The assistant returned a malformed spread — nothing was changed.");
349
+ return null;
350
+ }
351
+ if (parsed.segments.length === 0) {
352
+ setAssistError("The assistant wrote no usable segments — nothing was changed.");
353
+ return null;
354
+ }
355
+ return parsed;
356
+ },
357
+ [assistBusy, specKey, assistModel],
358
+ );
359
+
360
+ // ── the intent router (§1d) ──────────────────────────────────────────────
361
+ // Outside the busy gate on purpose (see the API doc above) and outside the error
362
+ // model too: this route ALWAYS returns 200, and a transport failure reads as a
363
+ // degraded "ambiguous" — the UI then offers both actions, which is the correct
364
+ // answer to "we don't know", not an error the user has to dismiss.
365
+ const classifyIntent = useCallback(
366
+ async (text: string, scope: "segment" | "movie" = "segment"): Promise<IntentResult> => {
367
+ const res = await request<unknown>(hugpyConfig.promptIntentUrl, {
368
+ method: "POST",
369
+ headers: { "Content-Type": "application/json" },
370
+ body: JSON.stringify({ text, scope }),
371
+ timeoutMs: 30_000,
372
+ meta: { specKey, operation: "prompt.intent" },
373
+ });
374
+ if (!res.ok) {
375
+ return { intent: "ambiguous", operation: null, confidence: 0, cached: false, degraded: true };
376
+ }
377
+ return readIntentResult(okValue(res));
378
+ },
379
+ [specKey],
380
+ );
381
+
382
+ return {
383
+ assistBusy,
384
+ assistError,
385
+ dismissError,
386
+ runAssist,
387
+ runNegative,
388
+ runSpread,
389
+ classifyIntent,
390
+ assistModels,
391
+ assistModel,
392
+ setAssistModel,
393
+ };
394
+ }
@@ -0,0 +1,221 @@
1
+ // GET /video/render/presets — WHAT THIS FLEET CAN ACTUALLY RENDER TODAY, read by the
2
+ // console instead of guessed at.
3
+ //
4
+ // This is the fourth and only MEASURED preset surface (video_intel/studio/presets.py:
5
+ // the eight ratified `RenderPreset` rows). The other three answer "what did we
6
+ // curate?"; this one answers "what will come back as pixels?" — and until 2026-07-29
7
+ // NOTHING in this SPA read it. The studio instead carried hand-maintained mirrors of
8
+ // backend facts (a static model-id list, a hand-curated capability union, a
9
+ // `STUDIO_REAL_FLOOR_GB = 6` that had drifted below every real row in the table), so a
10
+ // user could compose a request the server was always going to refuse and only find out
11
+ // at render time.
12
+ //
13
+ // WHAT THE PAYLOAD CARRIES, and why each half matters to a compatibility-aware UI:
14
+ // • `presets[]` — per row: capability, model, precision, geometry (width/height/fps),
15
+ // frame budget, `inputs[]` (what the CALLER must supply), `proven`
16
+ // (this exact path has produced pixels on this fleet), and the
17
+ // placement numbers (`vram_envelope_gb` = the minimum budget that can
18
+ // bind this model; `vram_need_gib` / `fits_render_box` = provenance).
19
+ // • `unavailable[]` — every capability the enum declares that NO preset covers, with
20
+ // the measured blocker and the full refusal text, straight from the
21
+ // registry's own `capability_verdict`. A picker can therefore show
22
+ // the dead options DISABLED with the real reason instead of hiding
23
+ // the fleet's shape from the user.
24
+ //
25
+ // COURTESY LAYER, NEVER A GATE. The server-side refusal (POST /video/studio/i2v's
26
+ // capability gate) stays authoritative — everything derived from this hook only decides
27
+ // what to OFFER, never what to allow. If this fetch fails the surfaces fall back to
28
+ // their prior hand-maintained lists and the server still refuses what it always did.
29
+ //
30
+ // SESSION-CACHED. The table is frozen data derived at request time from a registry that
31
+ // only changes on deploy, so one fetch per page load is enough; the cache is a module
32
+ // singleton shared by every mount (the Clip surface and the Movie composer both read
33
+ // it) rather than a fetch per component.
34
+ import { useCallback, useEffect, useRef, useState } from "react";
35
+ import { z } from "zod";
36
+ import { request, okValue, errorOf, describeAppError } from "../transport/client";
37
+ import { hugpyConfig } from "../config";
38
+
39
+ // Tolerant/passthrough, like every other wire schema in this tree: assert only what we
40
+ // read, so a payload that grows a field never breaks the parse.
41
+ export const renderPresetSchema = z
42
+ .object({
43
+ id: z.string(),
44
+ title: z.string(),
45
+ description: z.string().nullable().optional(),
46
+ /** The PRIMARY capability — what a caller asks for to reach this preset. */
47
+ capability: z.string(),
48
+ /** The full set (every ratified row serves exactly one today). */
49
+ capabilities: z.array(z.string()).nullable().optional(),
50
+ model: z.string(),
51
+ framework: z.string().nullable().optional(),
52
+ task: z.string().nullable().optional(),
53
+ precision: z.string().nullable().optional(),
54
+ /** Human geometry string; "source" on the two ffmpeg enhance rows. */
55
+ geometry: z.string().nullable().optional(),
56
+ // null on the enhance rows — they inherit the source clip's geometry.
57
+ width: z.number().nullable().optional(),
58
+ height: z.number().nullable().optional(),
59
+ fps: z.number().nullable().optional(),
60
+ default_frames: z.number().nullable().optional(),
61
+ max_frames: z.number().nullable().optional(),
62
+ /** What the CALLER must supply — the compatibility spine of this whole module. */
63
+ inputs: z.array(z.string()).nullable().optional(),
64
+ /** Checked claim: this exact path has produced pixels on this fleet. */
65
+ proven: z.boolean().nullable().optional(),
66
+ evidence: z.string().nullable().optional(),
67
+ /** MOVIE ONLY: the preset ids a multi-segment render composes + its joint modes. */
68
+ composes: z.array(z.string()).nullable().optional(),
69
+ joints: z.array(z.string()).nullable().optional(),
70
+ // Placement (added backend-side 2026-07-29). vram_envelope_gb is the MINIMUM
71
+ // vram_budget_gb that can bind this row's model; the other two are provenance.
72
+ vram_envelope_gb: z.number().nullable().optional(),
73
+ vram_need_gib: z.number().nullable().optional(),
74
+ fits_render_box: z.boolean().nullable().optional(),
75
+ })
76
+ .passthrough();
77
+ export type RenderPreset = z.infer<typeof renderPresetSchema>;
78
+
79
+ export const unavailableCapabilitySchema = z
80
+ .object({
81
+ capability: z.string(),
82
+ /** The measured blocker, one clause — good tooltip material. */
83
+ reason: z.string().nullable().optional(),
84
+ /** The full user-facing refusal, including the menu of what IS available. */
85
+ refusal: z.string().nullable().optional(),
86
+ })
87
+ .passthrough();
88
+ export type UnavailableCapability = z.infer<typeof unavailableCapabilitySchema>;
89
+
90
+ const renderPresetsResponseSchema = z
91
+ .object({
92
+ presets: z.array(renderPresetSchema),
93
+ unavailable: z.array(unavailableCapabilitySchema).nullable().optional(),
94
+ menu: z.string().nullable().optional(),
95
+ /** Wan requires num_frames == 4k+1; the runner snaps down to the nearest. */
96
+ frame_cadence: z.number().nullable().optional(),
97
+ render_box: z.string().nullable().optional(),
98
+ render_box_vram_gib: z.number().nullable().optional(),
99
+ })
100
+ .passthrough();
101
+ export type RenderPresetsPayload = z.infer<typeof renderPresetsResponseSchema>;
102
+
103
+ export interface RenderPresetsState {
104
+ presets: RenderPreset[];
105
+ unavailable: UnavailableCapability[];
106
+ menu: string;
107
+ frameCadence: number | null;
108
+ renderBox: string;
109
+ renderBoxVramGib: number | null;
110
+ loading: boolean;
111
+ error: string | null;
112
+ /** True once a fetch has SUCCEEDED — the "is it safe to filter by this?" flag.
113
+ * Every consumer must fall back to its unfiltered list while this is false, so a
114
+ * slow or failed discovery call never presents an empty picker. */
115
+ loaded: boolean;
116
+ refresh: () => void;
117
+ }
118
+
119
+ // ── module-singleton session cache ──────────────────────────────────────────
120
+ // One fetch per page load, shared by every mount. `inFlight` coalesces concurrent
121
+ // first-mounts (the Clip surface and the Movie composer mount together) into one call.
122
+ let cached: RenderPresetsPayload | null = null;
123
+ let inFlight: Promise<void> | null = null;
124
+ const subscribers = new Set<() => void>();
125
+
126
+ function notify(): void {
127
+ for (const fn of subscribers) fn();
128
+ }
129
+
130
+ async function fetchOnce(force: boolean): Promise<string | null> {
131
+ if (cached && !force) return null;
132
+ if (inFlight && !force) {
133
+ await inFlight;
134
+ return null;
135
+ }
136
+ let err: string | null = null;
137
+ const p = (async () => {
138
+ const res = await request<unknown>(hugpyConfig.renderPresetsUrl, {
139
+ meta: { specKey: "studio", operation: "render.presets.list" },
140
+ });
141
+ if (!res.ok) {
142
+ err = describeAppError(errorOf(res));
143
+ return;
144
+ }
145
+ const parsed = renderPresetsResponseSchema.safeParse(okValue(res));
146
+ if (!parsed.success) {
147
+ err = "Malformed render-presets response.";
148
+ return;
149
+ }
150
+ cached = parsed.data;
151
+ })();
152
+ inFlight = p;
153
+ try {
154
+ await p;
155
+ } finally {
156
+ inFlight = null;
157
+ notify();
158
+ }
159
+ return err;
160
+ }
161
+
162
+ /** TEST/DEV seam: drop the session cache so the next mount re-fetches. */
163
+ export function resetRenderPresetsCache(): void {
164
+ cached = null;
165
+ inFlight = null;
166
+ }
167
+
168
+ /**
169
+ * The fleet's measured render capability, as data. Never throws; a failed fetch
170
+ * reports `loaded:false` + an error string and every consumer keeps its fallback.
171
+ *
172
+ * `active` gates the network call the same way useStudioPresets/useMoviePresets do,
173
+ * so a surface that is not mounted costs nothing.
174
+ */
175
+ export function useRenderPresets(active = true): RenderPresetsState {
176
+ const [, bump] = useState(0);
177
+ const [error, setError] = useState<string | null>(null);
178
+ const [loading, setLoading] = useState(false);
179
+ const mounted = useRef(true);
180
+
181
+ useEffect(() => {
182
+ mounted.current = true;
183
+ const rerender = () => {
184
+ if (mounted.current) bump((n) => n + 1);
185
+ };
186
+ subscribers.add(rerender);
187
+ return () => {
188
+ mounted.current = false;
189
+ subscribers.delete(rerender);
190
+ };
191
+ }, []);
192
+
193
+ const run = useCallback(async (force: boolean) => {
194
+ if (cached && !force) return;
195
+ setLoading(true);
196
+ const err = await fetchOnce(force);
197
+ if (!mounted.current) return;
198
+ setLoading(false);
199
+ setError(err);
200
+ }, []);
201
+
202
+ useEffect(() => {
203
+ if (!active) return;
204
+ void run(false);
205
+ }, [active, run]);
206
+
207
+ const refresh = useCallback(() => void run(true), [run]);
208
+
209
+ return {
210
+ presets: cached?.presets ?? [],
211
+ unavailable: cached?.unavailable ?? [],
212
+ menu: cached?.menu ?? "",
213
+ frameCadence: cached?.frame_cadence ?? null,
214
+ renderBox: cached?.render_box ?? "",
215
+ renderBoxVramGib: cached?.render_box_vram_gib ?? null,
216
+ loading,
217
+ error,
218
+ loaded: cached != null,
219
+ refresh,
220
+ };
221
+ }