@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,262 @@
1
+ // Phase 5 — the one transport all hugpy calls go through.
2
+ //
3
+ // Placed at the shared hugpy level (sibling of config.ts) rather than under console/
4
+ // so the lower-layer receptacle upload can use it too without a console⇄receptacle
5
+ // import cycle. Every call gets: auth (configurable credentials), an AbortSignal,
6
+ // a timeout ceiling, GET-only retry/backoff, a correlation id, and a typed
7
+ // Result<T, AppError> return — failures cross the boundary as data, never as throws.
8
+ import { hugpyConfig } from "../config";
9
+ import { recordRequest, type Outcome } from "./telemetry";
10
+ import { videoShareKey } from "../share";
11
+
12
+ export type AppError =
13
+ | { kind: "network"; message: string; requestId: string }
14
+ | { kind: "timeout"; ms: number; requestId: string }
15
+ | { kind: "aborted"; requestId: string }
16
+ | { kind: "unauthorized"; status: number; requestId: string }
17
+ | {
18
+ kind: "server";
19
+ status: number;
20
+ message: string;
21
+ traceId?: string;
22
+ requestId: string;
23
+ };
24
+
25
+ export type Result<T> =
26
+ | { ok: true; value: T }
27
+ | { ok: false; error: AppError };
28
+
29
+ export interface RequestOptions {
30
+ method?: string;
31
+ body?: BodyInit | null;
32
+ headers?: Record<string, string>;
33
+ /** Caller-owned abort (component unmount, explicit Cancel). */
34
+ signal?: AbortSignal;
35
+ /** Per-call timeout ceiling; defaults to DEFAULT_TIMEOUT_MS. */
36
+ timeoutMs?: number;
37
+ /** Extra retries — honored for idempotent GET/HEAD only. */
38
+ retries?: number;
39
+ /**
40
+ * Body parse hint; default tries JSON then falls back to text. "arraybuffer"
41
+ * returns the raw bytes untouched (e.g. audio bytes for Web-Audio waveform
42
+ * decode) — the ONLY way to pull binary through this transport without a raw
43
+ * fetch.
44
+ */
45
+ expect?: "json" | "text" | "arraybuffer";
46
+ /** Caller context for telemetry — the transport doesn't know these. */
47
+ meta?: { specKey?: string; operation?: string };
48
+ }
49
+
50
+ const DEFAULT_TIMEOUT_MS = 120_000;
51
+ const DEFAULT_GET_RETRIES = 2;
52
+
53
+ let _counter = 0;
54
+ function newRequestId(): string {
55
+ try {
56
+ return crypto.randomUUID();
57
+ } catch {
58
+ _counter += 1;
59
+ const t = typeof performance !== "undefined" ? performance.now() : 0;
60
+ return `req_${_counter.toString(36)}_${Math.floor(t).toString(36)}`;
61
+ }
62
+ }
63
+
64
+ function isRecord(value: unknown): value is Record<string, unknown> {
65
+ return typeof value === "object" && value !== null && !Array.isArray(value);
66
+ }
67
+
68
+ function combineSignals(
69
+ caller: AbortSignal | undefined,
70
+ timeout: AbortSignal,
71
+ ): AbortSignal {
72
+ if (!caller) return timeout;
73
+ const anyFn = (AbortSignal as { any?: (s: AbortSignal[]) => AbortSignal }).any;
74
+ if (typeof anyFn === "function") return anyFn([caller, timeout]);
75
+
76
+ const ctrl = new AbortController();
77
+ if (caller.aborted || timeout.aborted) ctrl.abort();
78
+ else {
79
+ const onAbort = () => ctrl.abort();
80
+ caller.addEventListener("abort", onAbort, { once: true });
81
+ timeout.addEventListener("abort", onAbort, { once: true });
82
+ }
83
+ return ctrl.signal;
84
+ }
85
+
86
+ async function parseBody(
87
+ res: Response,
88
+ expect?: "json" | "text" | "arraybuffer",
89
+ ): Promise<unknown> {
90
+ // Binary bytes must be read before any text() call would mangle them.
91
+ if (expect === "arraybuffer") return res.arrayBuffer();
92
+ const text = await res.text();
93
+ if (expect === "text") return text;
94
+ try {
95
+ return JSON.parse(text);
96
+ } catch {
97
+ return text;
98
+ }
99
+ }
100
+
101
+ function serverMessage(parsed: unknown, status: number): string {
102
+ if (isRecord(parsed) && "error" in parsed) return String(parsed.error);
103
+ if (typeof parsed === "string" && parsed.trim()) return parsed;
104
+ return `HTTP ${status}`;
105
+ }
106
+
107
+ function backoff(attempt: number): Promise<void> {
108
+ return new Promise((resolve) => setTimeout(resolve, 250 * 2 ** attempt));
109
+ }
110
+
111
+ export async function request<T = unknown>(
112
+ url: string,
113
+ opts: RequestOptions = {},
114
+ ): Promise<Result<T>> {
115
+ const requestId = newRequestId();
116
+ const method = (opts.method ?? "GET").toUpperCase();
117
+ const idempotent = method === "GET" || method === "HEAD";
118
+ const maxAttempts = idempotent
119
+ ? Math.max(1, (opts.retries ?? DEFAULT_GET_RETRIES) + 1)
120
+ : 1;
121
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
122
+ const startedAt = typeof performance !== "undefined" ? performance.now() : 0;
123
+
124
+ let lastError: AppError = {
125
+ kind: "network",
126
+ message: "no attempt made",
127
+ requestId,
128
+ };
129
+ let final: Result<T> | null = null;
130
+ let finalStatus: number | undefined;
131
+ let finalTraceId: string | undefined;
132
+
133
+ for (let attempt = 0; attempt < maxAttempts && !final; attempt++) {
134
+ const timeoutCtrl = new AbortController();
135
+ const timer = setTimeout(() => timeoutCtrl.abort(), timeoutMs);
136
+
137
+ try {
138
+ const res = await fetch(url, {
139
+ method,
140
+ body: opts.body ?? null,
141
+ // Only attach the correlation header when explicitly enabled — a custom header
142
+ // forces a CORS preflight that the upload endpoint may not answer.
143
+ // k9: a video-share session stamps its credential on every XHR here (the
144
+ // single fetch choke point). Same-origin (dev.hugpy.ai → /api) so no CORS
145
+ // preflight; absent for a normal console/operator session (no stored key).
146
+ headers: {
147
+ ...(hugpyConfig.sendRequestId ? { "X-Request-Id": requestId } : {}),
148
+ ...(videoShareKey() ? { "X-Video-Share": videoShareKey() as string } : {}),
149
+ ...(opts.headers ?? {}),
150
+ },
151
+ signal: combineSignals(opts.signal, timeoutCtrl.signal),
152
+ credentials: hugpyConfig.withCredentials ? "include" : "same-origin",
153
+ });
154
+ clearTimeout(timer);
155
+ finalStatus = res.status;
156
+
157
+ if (res.status === 401 || res.status === 403) {
158
+ final = {
159
+ ok: false,
160
+ error: { kind: "unauthorized", status: res.status, requestId },
161
+ };
162
+ break;
163
+ }
164
+
165
+ const parsed = await parseBody(res, opts.expect);
166
+
167
+ if (!res.ok) {
168
+ const traceId =
169
+ res.headers.get("x-trace-id") ??
170
+ res.headers.get("x-request-id") ??
171
+ undefined;
172
+ finalTraceId = traceId;
173
+ const error: AppError = {
174
+ kind: "server",
175
+ status: res.status,
176
+ message: serverMessage(parsed, res.status),
177
+ ...(traceId ? { traceId } : {}),
178
+ requestId,
179
+ };
180
+ if (idempotent && res.status >= 500 && attempt < maxAttempts - 1) {
181
+ lastError = error;
182
+ await backoff(attempt);
183
+ continue;
184
+ }
185
+ final = { ok: false, error };
186
+ break;
187
+ }
188
+
189
+ final = { ok: true, value: parsed as T };
190
+ } catch (e) {
191
+ clearTimeout(timer);
192
+
193
+ // Caller aborted (unmount / Cancel) wins over timeout/network classification.
194
+ if (opts.signal?.aborted) {
195
+ final = { ok: false, error: { kind: "aborted", requestId } };
196
+ break;
197
+ }
198
+ if (timeoutCtrl.signal.aborted) {
199
+ lastError = { kind: "timeout", ms: timeoutMs, requestId };
200
+ } else {
201
+ lastError = {
202
+ kind: "network",
203
+ message: e instanceof Error ? e.message : String(e),
204
+ requestId,
205
+ };
206
+ }
207
+ if (idempotent && attempt < maxAttempts - 1) {
208
+ await backoff(attempt);
209
+ continue;
210
+ }
211
+ final = { ok: false, error: lastError };
212
+ }
213
+ }
214
+
215
+ const result: Result<T> = final ?? { ok: false, error: lastError };
216
+ const outcome: Outcome = result.ok ? "ok" : errorOf(result).kind;
217
+
218
+ recordRequest({
219
+ requestId,
220
+ url,
221
+ method,
222
+ outcome,
223
+ durationMs: Math.round(
224
+ (typeof performance !== "undefined" ? performance.now() : 0) - startedAt,
225
+ ),
226
+ ...(finalStatus != null ? { status: finalStatus } : {}),
227
+ ...(finalTraceId ? { traceId: finalTraceId } : {}),
228
+ ...(opts.meta?.specKey ? { specKey: opts.meta.specKey } : {}),
229
+ ...(opts.meta?.operation ? { operation: opts.meta.operation } : {}),
230
+ });
231
+
232
+ return result;
233
+ }
234
+
235
+ // Guarded Result accessors. TS only narrows discriminated unions with strictNullChecks,
236
+ // which is OFF in the app's root tsconfig — so call sites can't rely on `if (r.ok)` to
237
+ // narrow the negative branch. Each accessor centralizes a single cast; call only after
238
+ // checking `r.ok` (errorOf) / in the ok branch (okValue).
239
+ export function errorOf<T>(r: Result<T>): AppError {
240
+ return (r as { error: AppError }).error;
241
+ }
242
+ export function okValue<T>(r: Result<T>): T {
243
+ return (r as { value: T }).value;
244
+ }
245
+
246
+ /** Human-readable, kind-aware message for surfacing an AppError in the UI. */
247
+ export function describeAppError(error: AppError): string {
248
+ switch (error.kind) {
249
+ case "unauthorized":
250
+ return "Your session has expired. Please sign in again.";
251
+ case "timeout":
252
+ return `The request timed out after ${Math.round(error.ms / 1000)}s.`;
253
+ case "aborted":
254
+ return "Cancelled.";
255
+ case "network":
256
+ return `Network error: ${error.message}`;
257
+ case "server":
258
+ return error.traceId
259
+ ? `${error.message} (trace ${error.traceId})`
260
+ : error.message;
261
+ }
262
+ }
@@ -0,0 +1,97 @@
1
+ import type { AppError } from "./client";
2
+
3
+ // Phase 10 — observability at the transport boundary. Every request emits one structured
4
+ // event (request id, target, duration, outcome, + optional spec/op from the caller). A
5
+ // pluggable sink defaults to structured console logging and can be swapped for Sentry or
6
+ // any reporter via setTelemetrySink. In-memory metrics aggregate counts/error-rate/latency
7
+ // for a debug surface.
8
+
9
+ export type Outcome = "ok" | AppError["kind"];
10
+
11
+ export interface RequestEvent {
12
+ requestId: string;
13
+ url: string;
14
+ method: string;
15
+ outcome: Outcome;
16
+ durationMs: number;
17
+ status?: number;
18
+ /** Caller context — the transport doesn't know these, so they're passed in. */
19
+ specKey?: string;
20
+ operation?: string;
21
+ /** Server/Sentry correlation id, when present. */
22
+ traceId?: string;
23
+ }
24
+
25
+ export interface TelemetrySink {
26
+ record(event: RequestEvent): void;
27
+ }
28
+
29
+ // ---- metrics ----
30
+ const countByOp = new Map<string, number>();
31
+ const errorByKind = new Map<Outcome, number>();
32
+ const latencyByOp = new Map<string, number[]>();
33
+
34
+ function percentile(values: number[], p: number): number {
35
+ if (!values.length) return 0;
36
+ const sorted = [...values].sort((a, b) => a - b);
37
+ const idx = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
38
+ return sorted[Math.max(0, idx)] ?? 0;
39
+ }
40
+
41
+ export const metrics = {
42
+ snapshot() {
43
+ const latency: Record<string, { p50: number; p95: number; n: number }> = {};
44
+ for (const [op, samples] of latencyByOp) {
45
+ latency[op] = {
46
+ p50: Math.round(percentile(samples, 50)),
47
+ p95: Math.round(percentile(samples, 95)),
48
+ n: samples.length,
49
+ };
50
+ }
51
+ return {
52
+ countByOp: Object.fromEntries(countByOp),
53
+ errorByKind: Object.fromEntries(errorByKind),
54
+ latency,
55
+ };
56
+ },
57
+ reset() {
58
+ countByOp.clear();
59
+ errorByKind.clear();
60
+ latencyByOp.clear();
61
+ },
62
+ };
63
+
64
+ // ---- sink (default: structured console; swap for Sentry via setTelemetrySink) ----
65
+ const consoleSink: TelemetrySink = {
66
+ record(event) {
67
+ const line = { evt: "hugpy.request", ...event };
68
+ if (event.outcome === "ok") {
69
+ console.debug(line);
70
+ } else {
71
+ console.warn(line);
72
+ }
73
+ },
74
+ };
75
+
76
+ let sink: TelemetrySink = consoleSink;
77
+
78
+ export function setTelemetrySink(next: TelemetrySink): void {
79
+ sink = next;
80
+ }
81
+
82
+ export function recordRequest(event: RequestEvent): void {
83
+ const op = event.operation ?? event.specKey ?? event.method;
84
+ countByOp.set(op, (countByOp.get(op) ?? 0) + 1);
85
+ if (event.outcome !== "ok") {
86
+ errorByKind.set(event.outcome, (errorByKind.get(event.outcome) ?? 0) + 1);
87
+ }
88
+ const samples = latencyByOp.get(op) ?? [];
89
+ samples.push(event.durationMs);
90
+ latencyByOp.set(op, samples);
91
+
92
+ try {
93
+ sink.record(event);
94
+ } catch {
95
+ // a telemetry failure must never break a request
96
+ }
97
+ }
@@ -0,0 +1,122 @@
1
+ // Shared media-intake receptacle — the drag/drop + click-to-browse surface the
2
+ // four stations mount in place of their empty item-view. NO upload logic lives
3
+ // here: the station passes its EXISTING upload handler (onPick / onPickVideo) as
4
+ // `onFile`, and this component only surfaces the single file the user drops or
5
+ // picks, then hands it straight back. Mirrors the media arm's receptacle
6
+ // interaction — a dashed `.vi-dropzone`, a hidden file input, a drag-active
7
+ // highlight, keyboard activation — but authors it as literal `.vi-*` CSS here.
8
+ // Single file by design: every station ingests exactly one source at a time.
9
+ import { useRef, useState } from "react";
10
+ import type { ChangeEvent, DragEvent, KeyboardEvent } from "react";
11
+
12
+ export interface DropReceptacleProps {
13
+ /** The station's existing upload handler — receives the picked/dropped file. */
14
+ onFile: (file: File) => void;
15
+ /** Accept filter for the hidden <input> (e.g. "image/*", "audio/*,video/*"). */
16
+ accept: string;
17
+ /** Disable + show a "Working…" affordance while the station uploads/ingests. */
18
+ busy?: boolean;
19
+ /** Primary invite line. Falls back to the stable deploy marker below. */
20
+ title?: string;
21
+ /** Small station-specific secondary line. */
22
+ hint?: string;
23
+ /** Optional extra class on the dropzone (e.g. a compact variant). */
24
+ className?: string;
25
+ }
26
+
27
+ // Stable deploy marker — a literal that survives minification (grepped
28
+ // post-deploy as the "new receptacle marker"). Doubles as the default title.
29
+ const DROP_MARKER = "Drop a file or click to browse";
30
+
31
+ export function DropReceptacle({
32
+ onFile,
33
+ accept,
34
+ busy = false,
35
+ title,
36
+ hint,
37
+ className,
38
+ }: DropReceptacleProps) {
39
+ const inputRef = useRef<HTMLInputElement>(null);
40
+ const [dragActive, setDragActive] = useState(false);
41
+
42
+ function openPicker() {
43
+ if (busy) return;
44
+ inputRef.current?.click();
45
+ }
46
+
47
+ function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
48
+ if (busy) return;
49
+ if (e.key === "Enter" || e.key === " ") {
50
+ e.preventDefault();
51
+ openPicker();
52
+ }
53
+ }
54
+
55
+ function onChange(e: ChangeEvent<HTMLInputElement>) {
56
+ const file = e.target.files?.[0];
57
+ if (file) onFile(file);
58
+ // Reset so re-picking the SAME file re-fires onChange.
59
+ e.target.value = "";
60
+ }
61
+
62
+ function onDragOver(e: DragEvent<HTMLDivElement>) {
63
+ if (busy) return;
64
+ e.preventDefault();
65
+ setDragActive(true);
66
+ }
67
+ function onDragEnter(e: DragEvent<HTMLDivElement>) {
68
+ if (busy) return;
69
+ e.preventDefault();
70
+ setDragActive(true);
71
+ }
72
+ function onDragLeave() {
73
+ setDragActive(false);
74
+ }
75
+ function onDrop(e: DragEvent<HTMLDivElement>) {
76
+ e.preventDefault();
77
+ setDragActive(false);
78
+ if (busy) return;
79
+ const file = e.dataTransfer.files?.[0];
80
+ if (file) onFile(file);
81
+ }
82
+
83
+ const cls =
84
+ "vi-dropzone" +
85
+ (dragActive ? " vi-dropzone--active" : "") +
86
+ (busy ? " vi-dropzone--busy" : "") +
87
+ (className ? ` ${className}` : "");
88
+
89
+ return (
90
+ <div
91
+ className={cls}
92
+ role="button"
93
+ tabIndex={0}
94
+ aria-disabled={busy}
95
+ onClick={openPicker}
96
+ onKeyDown={onKeyDown}
97
+ onDragOver={onDragOver}
98
+ onDragEnter={onDragEnter}
99
+ onDragLeave={onDragLeave}
100
+ onDrop={onDrop}
101
+ >
102
+ <input
103
+ ref={inputRef}
104
+ type="file"
105
+ accept={accept}
106
+ className="vi-file-input"
107
+ onChange={onChange}
108
+ disabled={busy}
109
+ />
110
+ {busy ? (
111
+ <span className="vi-dropzone-title">Working…</span>
112
+ ) : (
113
+ <>
114
+ <span className="vi-dropzone-title">{title ?? DROP_MARKER}</span>
115
+ {hint ? <span className="vi-dropzone-hint">{hint}</span> : null}
116
+ </>
117
+ )}
118
+ </div>
119
+ );
120
+ }
121
+
122
+ export default DropReceptacle;
@@ -0,0 +1,118 @@
1
+ // The PROMPT-ASSIST button cluster — the presentational half of usePromptAssist.
2
+ // Renders the same .vi-prompt-assist row GenerateStation shipped (an "prompt assist"
3
+ // lead + Enhance + Generate + a dismissible inline error), so every composer that
4
+ // adopts the shared hook gets an identical look. It owns NO state: the host wires
5
+ // the two clicks (Enhance passes its draft; Generate seeds from it) and passes the
6
+ // hook's busy/error values straight through.
7
+ import type { AssistBusyMode, AssistModel } from "./usePromptAssist";
8
+
9
+ export interface PromptAssistButtonsProps {
10
+ /**
11
+ * The active in-flight mode (from usePromptAssist), or null when idle. Typed as the
12
+ * WIDER busy union so a host that also runs spread/negative calls can hand its
13
+ * `assistBusy` straight through and have both buttons correctly disable — the two
14
+ * labels below still key off "detail"/"generate" only.
15
+ */
16
+ busy: AssistBusyMode | null;
17
+ /** The dismissible inline error (from usePromptAssist), or null. */
18
+ error: string | null;
19
+ /** Clear the inline error. */
20
+ onDismissError: () => void;
21
+ /** Fire Enhance ("detail") — enriches the current draft. Disabled when `canEnhance` is false. */
22
+ onEnhance: () => void;
23
+ /** Fire Generate ("generate") — writes a full prompt from scratch (seeded by any current text). */
24
+ onGenerate: () => void;
25
+ /** Enhance needs a non-empty draft; the host computes this from its own prompt text. */
26
+ canEnhance: boolean;
27
+ /**
28
+ * The text generators the fleet can actually run (from usePromptAssist). OPTIONAL and
29
+ * defaulting to empty: a host that hasn't adopted the picker renders exactly the old
30
+ * cluster, so adoption stays a per-composer flip rather than a breaking change.
31
+ */
32
+ models?: AssistModel[];
33
+ /** The chosen generator, or null for the fleet default. */
34
+ model?: string | null;
35
+ /** Choose a generator (null = "use the fleet default"). */
36
+ onModelChange?: (model: string | null) => void;
37
+ }
38
+
39
+ export function PromptAssistButtons({
40
+ busy,
41
+ error,
42
+ onDismissError,
43
+ onEnhance,
44
+ onGenerate,
45
+ canEnhance,
46
+ models = [],
47
+ model = null,
48
+ onModelChange,
49
+ }: PromptAssistButtonsProps) {
50
+ // Only render the picker once discovery has returned something AND the host wired a
51
+ // handler. An empty list means the fleet offered nothing (or discovery failed) — in
52
+ // that case assist still works on the backend default, so showing an empty <select>
53
+ // would advertise a choice that isn't there.
54
+ const showPicker = onModelChange != null && models.length > 0;
55
+ return (
56
+ <div className="vi-prompt-assist">
57
+ <span className="vi-prompt-assist-lead" aria-hidden="true">
58
+ prompt assist
59
+ </span>
60
+ {showPicker && (
61
+ <select
62
+ className="vi-select vi-select-sm"
63
+ value={model ?? ""}
64
+ disabled={busy != null}
65
+ onChange={(e) => onModelChange?.(e.target.value || null)}
66
+ aria-label="Text generator used to enhance or generate prompts"
67
+ title={
68
+ "Which text generator writes the prompt. Thinking is suppressed for all of " +
69
+ "them, so a reasoning model can't fill the box with its monologue."
70
+ }
71
+ >
72
+ <option value="">fleet default</option>
73
+ {models.map((m) => (
74
+ // "· cold" is not decoration: a non-seated model pays a load on first use,
75
+ // which is minutes, not milliseconds. Saying so up front stops that reading
76
+ // as a hang.
77
+ <option key={m.model} value={m.model}>
78
+ {m.model}
79
+ {m.serving ? "" : " · cold"}
80
+ </option>
81
+ ))}
82
+ </select>
83
+ )}
84
+ <button
85
+ type="button"
86
+ className="vi-btn vi-btn-sm vi-btn-ghost"
87
+ onClick={onEnhance}
88
+ disabled={!canEnhance || busy != null}
89
+ title="Enrich your current prompt with more descriptive detail"
90
+ >
91
+ {busy === "detail" ? "✨ Enhancing…" : "✨ Enhance"}
92
+ </button>
93
+ <button
94
+ type="button"
95
+ className="vi-btn vi-btn-sm vi-btn-ghost"
96
+ onClick={onGenerate}
97
+ disabled={busy != null}
98
+ title="Write a full prompt from scratch (uses any current text as a theme)"
99
+ >
100
+ {busy === "generate" ? "✨ Generating…" : "✨ Generate"}
101
+ </button>
102
+ {error && (
103
+ <span className="vi-prompt-assist-error" role="alert">
104
+ {error}
105
+ <button
106
+ type="button"
107
+ className="vi-prompt-assist-dismiss"
108
+ onClick={onDismissError}
109
+ aria-label="Dismiss assist error"
110
+ title="Dismiss"
111
+ >
112
+ ✕
113
+ </button>
114
+ </span>
115
+ )}
116
+ </div>
117
+ );
118
+ }
@@ -0,0 +1,66 @@
1
+ // Tiny seam letting the shell-level session-library sidebar STAGE a request into
2
+ // the Generate station's ordered composer while Generate is mounted — the same
3
+ // entry point the Run/Submit path already reads (model + mode + ordered parts),
4
+ // pre-filled. There is NO second generation pipeline: staging just drives the
5
+ // station's own setters, then the user reviews and hits Run.
6
+ //
7
+ // Single-handler by design: exactly one GenerateStation is ever mounted (the
8
+ // /generate route). Because Continue / Replicate fire from the shell sidebar on
9
+ // ANY route — before Generate has mounted — a stage request raised while no
10
+ // handler is registered is BUFFERED and drained the instant the station
11
+ // registers (the sidebar navigates to /generate alongside the call). No deps;
12
+ // never touches server media.
13
+ import type { MediaRef } from "./contract";
14
+
15
+ export interface PendingPart {
16
+ ref: MediaRef;
17
+ origin: string;
18
+ label?: string;
19
+ }
20
+
21
+ /**
22
+ * A staged composer request. Every field is optional and additive: `model` /
23
+ * `mode` drive the station's knobs, `text` appends a text prompt part, `parts`
24
+ * appends media parts (in order), and `replace` clears the composer first so a
25
+ * Replicate / Continue stages a clean prompt rather than appending to whatever
26
+ * was already there.
27
+ */
28
+ export interface StageRequest {
29
+ model?: string;
30
+ mode?: "image" | "scene";
31
+ text?: string;
32
+ parts?: PendingPart[];
33
+ replace?: boolean;
34
+ }
35
+
36
+ type StageHandler = (req: StageRequest) => void;
37
+ let handler: StageHandler | null = null;
38
+ let pending: StageRequest[] = [];
39
+
40
+ /**
41
+ * GenerateStation registers its stager while mounted. On register we drain any
42
+ * buffered request (raised from the sidebar before the station mounted). Returns
43
+ * an unregister.
44
+ */
45
+ export function registerComposer(fn: StageHandler): () => void {
46
+ handler = fn;
47
+ if (pending.length > 0) {
48
+ const queued = pending;
49
+ pending = [];
50
+ for (const req of queued) fn(req);
51
+ }
52
+ return () => {
53
+ if (handler === fn) handler = null;
54
+ };
55
+ }
56
+
57
+ /** Ask the mounted composer to apply `req`. Buffered if Generate isn't mounted yet. */
58
+ export function requestStage(req: StageRequest): void {
59
+ if (handler) handler(req);
60
+ else pending.push(req);
61
+ }
62
+
63
+ /** Back-compat convenience: append a single media part to the composer. */
64
+ export function requestAddPart(part: PendingPart): void {
65
+ requestStage({ parts: [part] });
66
+ }