@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
+ // IDENTITY PROFILES — the shared "Identity" control block both studio surfaces
2
+ // (the Clip Generate tab + the Movie composer) embed as ONE additive row in their
3
+ // existing identity/reference area. It does NOT re-draw the reference thumbnails —
4
+ // the host surface's own chip UI already shows whatever is attached; this block adds
5
+ // only the profile affordances on top:
6
+ //
7
+ // • "👤 From profile" — a picker of saved profiles (name + first-ref thumbnail);
8
+ // picking one RESOLVES its saved paths back to MediaRefs and hands them to the
9
+ // host (replace-semantics: a profile IS the whole identity), and binds the slug.
10
+ // • "💾 Save as identity profile" — enabled once ≥1 reference is attached and the
11
+ // current identity is UNSAVED; prompts for a name (window.prompt, v0) and POSTs.
12
+ //
13
+ // DOCTRINE (operator 2026-07-12): present ONE concept — "Identity" — backed by
14
+ // profiles. A chosen profile shows as a named, saved identity; ad-hoc uploads are an
15
+ // honest "unsaved identity" with the save affordance (the incomplete case is STILL
16
+ // the identity, just unnamed). The host prefers sending `identity_profile:<slug>` when
17
+ // a profile is bound; raw reference_images only for the unsaved case. (Stage (b) —
18
+ // turnaround generation + the re-edit loop that promotes an approved rendering to the
19
+ // canonical reference — layers on top of this next.)
20
+ import { useState, type MouseEvent } from "react";
21
+ import { mediaBytesUrl } from "../../config";
22
+ import type { MediaRef } from "../../video/contract";
23
+ import { useIdentityProfiles } from "./useIdentityProfiles";
24
+ import type { IdentityProfile } from "./useIdentityProfiles";
25
+
26
+ /** The profile currently bound as the identity (or null for an unsaved/empty one). */
27
+ export interface SelectedProfile {
28
+ slug: string;
29
+ name: string;
30
+ }
31
+
32
+ export interface IdentityProfileControlsProps {
33
+ /** How many identity references are attached right now (gates Save + status copy). */
34
+ refCount: number;
35
+ /** The raw jailed URIs of the attached references — what Save persists. */
36
+ currentRefUris: string[];
37
+ /** The bound profile, if the current identity came from one (else null = unsaved). */
38
+ selectedProfile: SelectedProfile | null;
39
+ /** Attach a profile's resolved reference set into the host's OWN reference state.
40
+ * `replace` is true for a profile pick (the profile is the whole identity). */
41
+ onAttach: (refs: MediaRef[], replace: boolean) => void;
42
+ /** Bind (or clear, with null) the profile slug the host sends as identity_profile. */
43
+ onSelectProfile: (p: SelectedProfile | null) => void;
44
+ /** Upper bound on references (4) — advisory copy only. */
45
+ maxRefs: number;
46
+ /** Disable all affordances (host is busy / submitting). */
47
+ disabled?: boolean;
48
+ }
49
+
50
+ export function IdentityProfileControls({
51
+ refCount,
52
+ currentRefUris,
53
+ selectedProfile,
54
+ onAttach,
55
+ onSelectProfile,
56
+ maxRefs,
57
+ disabled,
58
+ }: IdentityProfileControlsProps) {
59
+ const { profiles, loading, error, create, remove, resolveRefs } = useIdentityProfiles();
60
+ const [open, setOpen] = useState(false);
61
+ const [busy, setBusy] = useState(false);
62
+ const [msg, setMsg] = useState<string | null>(null);
63
+
64
+ const canSave = refCount > 0 && selectedProfile == null && !disabled && !busy;
65
+
66
+ async function pick(profile: IdentityProfile) {
67
+ if (disabled || busy) return;
68
+ setBusy(true);
69
+ setMsg(null);
70
+ try {
71
+ const refs = await resolveRefs(profile);
72
+ if (refs.length === 0) {
73
+ setMsg("Could not load that profile's reference images.");
74
+ return;
75
+ }
76
+ // A profile IS the identity — replace the current set with the profile's.
77
+ onAttach(refs.slice(0, maxRefs), true);
78
+ onSelectProfile({ slug: profile.slug, name: profile.name });
79
+ setOpen(false);
80
+ setMsg(`Identity set to “${profile.name}”.`);
81
+ } finally {
82
+ setBusy(false);
83
+ }
84
+ }
85
+
86
+ async function save() {
87
+ if (!canSave) return;
88
+ const name = window.prompt("Name this identity profile:");
89
+ if (name == null) return; // cancelled
90
+ const trimmed = name.trim();
91
+ if (!trimmed) {
92
+ setMsg("A name is required to save an identity profile.");
93
+ return;
94
+ }
95
+ setBusy(true);
96
+ setMsg(null);
97
+ try {
98
+ const res = await create(trimmed, currentRefUris);
99
+ if (res.ok && res.profile) {
100
+ // The just-saved profile becomes the bound identity (its refs are already
101
+ // attached), flipping the surface from "unsaved" to a named profile.
102
+ onSelectProfile({ slug: res.profile.slug, name: res.profile.name });
103
+ setMsg(`Saved identity profile “${res.profile.name}”.`);
104
+ } else if (res.duplicate) {
105
+ setMsg(`An identity profile named “${trimmed}” already exists — pick it from 👤 From profile, or choose another name.`);
106
+ } else {
107
+ setMsg(res.message || "Could not save the identity profile.");
108
+ }
109
+ } finally {
110
+ setBusy(false);
111
+ }
112
+ }
113
+
114
+ async function archive(profile: IdentityProfile, e: MouseEvent) {
115
+ e.stopPropagation();
116
+ if (disabled || busy) return;
117
+ if (!window.confirm(`Archive identity profile “${profile.name}”? (It is kept, not erased.)`)) return;
118
+ setBusy(true);
119
+ try {
120
+ await remove(profile.slug);
121
+ if (selectedProfile?.slug === profile.slug) onSelectProfile(null);
122
+ } finally {
123
+ setBusy(false);
124
+ }
125
+ }
126
+
127
+ return (
128
+ <div style={{ marginTop: "0.4rem" }}>
129
+ <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
130
+ <button
131
+ type="button"
132
+ className="vi-btn vi-btn-sm vi-btn-ghost"
133
+ disabled={disabled || busy}
134
+ aria-expanded={open}
135
+ onClick={() => setOpen((v) => !v)}
136
+ title="Attach a saved identity profile's reference set"
137
+ >
138
+ {open ? "Hide profiles" : `👤 From profile${profiles.length ? ` (${profiles.length})` : ""}`}
139
+ </button>
140
+ <button
141
+ type="button"
142
+ className="vi-btn vi-btn-sm vi-btn-ghost"
143
+ disabled={!canSave}
144
+ onClick={() => void save()}
145
+ title={
146
+ refCount === 0
147
+ ? "Attach at least one reference image first"
148
+ : selectedProfile != null
149
+ ? "This identity is already a saved profile"
150
+ : "Save these references as a reusable identity profile"
151
+ }
152
+ >
153
+ 💾 Save as identity profile
154
+ </button>
155
+ {selectedProfile != null ? (
156
+ <span className="vi-comfy-hint" role="status" style={{ opacity: 0.85 }}>
157
+ Identity: <strong>{selectedProfile.name}</strong> — saved profile ✓
158
+ <button
159
+ type="button"
160
+ className="vi-btn vi-btn-sm vi-btn-ghost"
161
+ disabled={disabled || busy}
162
+ onClick={() => onSelectProfile(null)}
163
+ title="Unbind the profile (keep the images as an unsaved identity)"
164
+ style={{ marginLeft: "0.4rem", padding: "0 0.35rem" }}
165
+ >
166
+ unbind
167
+ </button>
168
+ </span>
169
+ ) : refCount > 0 ? (
170
+ <span className="vi-comfy-hint" role="status" style={{ opacity: 0.75 }}>
171
+ Unsaved identity — save it to reuse across clips &amp; movies.
172
+ </span>
173
+ ) : null}
174
+ </div>
175
+
176
+ {open && (
177
+ <div
178
+ style={{
179
+ display: "flex",
180
+ flexWrap: "wrap",
181
+ gap: "0.5rem",
182
+ marginTop: "0.5rem",
183
+ maxHeight: "12rem",
184
+ overflowY: "auto",
185
+ }}
186
+ >
187
+ {loading ? (
188
+ <p className="vi-comfy-hint">Loading identity profiles…</p>
189
+ ) : error ? (
190
+ <p className="vi-error" role="alert">{error}</p>
191
+ ) : profiles.length === 0 ? (
192
+ <p className="vi-comfy-hint">
193
+ No saved identity profiles yet — attach reference image(s) and press
194
+ “💾 Save as identity profile” to create one.
195
+ </p>
196
+ ) : (
197
+ profiles.map((p) => (
198
+ <button
199
+ key={p.slug}
200
+ type="button"
201
+ className="vi-btn vi-btn-ghost"
202
+ disabled={disabled || busy}
203
+ onClick={() => void pick(p)}
204
+ title={`Use “${p.name}” (${p.reference_images.length} reference${
205
+ p.reference_images.length === 1 ? "" : "s"
206
+ })`}
207
+ style={{
208
+ display: "flex",
209
+ flexDirection: "column",
210
+ alignItems: "center",
211
+ gap: "0.2rem",
212
+ padding: "0.3rem",
213
+ position: "relative",
214
+ }}
215
+ >
216
+ <img
217
+ src={mediaBytesUrl(p.reference_images[0])}
218
+ alt={p.name}
219
+ style={{
220
+ width: "4.5rem",
221
+ height: "4.5rem",
222
+ objectFit: "cover",
223
+ borderRadius: "0.35rem",
224
+ background: "#000",
225
+ }}
226
+ />
227
+ <span style={{ fontSize: "0.75rem", maxWidth: "5rem", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
228
+ {p.name}
229
+ </span>
230
+ <span
231
+ role="button"
232
+ tabIndex={-1}
233
+ aria-label={`Archive ${p.name}`}
234
+ onClick={(e) => void archive(p, e)}
235
+ title="Archive (kept, not erased)"
236
+ style={{
237
+ position: "absolute",
238
+ top: -4,
239
+ right: -4,
240
+ background: "var(--vi-border)",
241
+ borderRadius: "0.3rem",
242
+ padding: "0 0.3rem",
243
+ fontSize: "0.72rem",
244
+ lineHeight: 1.4,
245
+ }}
246
+ >
247
+ ✕
248
+ </span>
249
+ </button>
250
+ ))
251
+ )}
252
+ </div>
253
+ )}
254
+
255
+ {msg && (
256
+ <p className="vi-comfy-hint" role="status" style={{ marginTop: "0.3rem" }}>
257
+ {msg}
258
+ </p>
259
+ )}
260
+ </div>
261
+ );
262
+ }
@@ -0,0 +1,224 @@
1
+ // ACTIVE PROCESSES tab — the in-flight STUDIO jobs (queued / claimed / running) as a
2
+ // focused live panel. Each row is EXPANDABLE into the exhaustive per-process view:
3
+ // the stage TIMELINE (what it did + what it's doing now), the current-activity line,
4
+ // an honest stall indicator tied to the current stage, and — on failure — the exact
5
+ // failing stage + error code + message. This replaces the old "expands to a loading
6
+ // bar that never populates" with real, server-sent detail (no fabricated bars).
7
+ //
8
+ // SCOPE (operator, 2026-07-31): this panel is fed by GET /video/studio/clips, which is
9
+ // `WHERE name='studio_i2v'` — so it lists ONLY studio-originated renders, never general
10
+ // fleet model calls. Cancel here therefore only ever stops a studio render.
11
+ //
12
+ // The GET /video/studio/clips route projects `progress` (incl. the reservation
13
+ // awaiting_capacity HOLD marker), a `placement` object, and the new stage-timeline
14
+ // telemetry (stage_log / failure / current_stage / last_movement_ts). Terminal rows
15
+ // (done / failed / cancelled) stay in the Library tab, where their full timeline +
16
+ // failure detail is inspectable via the Details expander.
17
+ import { useCallback, useEffect, useRef, useState } from "react";
18
+ import { request } from "../../transport/client";
19
+ import { jobCancelUrl } from "../../config";
20
+ import {
21
+ shortId,
22
+ ProcessTimeline,
23
+ isStalled,
24
+ type Clip,
25
+ type StageEntry,
26
+ type JobFailure,
27
+ } from "./studioShared";
28
+ import { placementLine, isExternal, reservedGib, type Placement } from "../../video/useMediaJobs";
29
+
30
+ const IN_FLIGHT = new Set(["queued", "claimed", "running", "cancelling"]);
31
+ const TERMINAL = new Set(["done", "failed", "cancelled"]);
32
+
33
+ function elapsed(c: Clip): string {
34
+ const t = c.created ?? c.updated;
35
+ if (t == null) return "";
36
+ const secs = Math.max(0, Math.floor(Date.now() / 1000 - t));
37
+ if (secs < 60) return `${secs}s`;
38
+ const m = Math.floor(secs / 60);
39
+ return `${m}m ${secs % 60}s`;
40
+ }
41
+
42
+ // The clip's `placement` rides the wire in snake_case; adapt it to the camelCase
43
+ // Placement the shared render helpers expect. null when the clip carries none.
44
+ function clipPlacement(c: Clip): Placement | null {
45
+ const p = (c as { placement?: Record<string, unknown> | null }).placement;
46
+ if (!p || typeof p !== "object") return null;
47
+ const s = (v: unknown) => (typeof v === "string" && v ? v : null);
48
+ const n = (v: unknown) => (typeof v === "number" && isFinite(v) ? v : null);
49
+ return {
50
+ source: s(p.source),
51
+ host: s(p.host),
52
+ workerId: s(p.worker_id),
53
+ gpu: s(p.gpu),
54
+ process: s(p.process),
55
+ reservedBytes: n(p.reserved_bytes),
56
+ };
57
+ }
58
+
59
+ // The effective phase chip: the reservation awaiting_capacity HOLD overrides the
60
+ // raw status (the clip's progress blob carries the marker), else the raw status.
61
+ function clipPhase(c: Clip): string {
62
+ const prog = (c as { progress?: Record<string, unknown> | null }).progress;
63
+ if (prog && typeof prog === "object" && prog.phase === "awaiting_capacity")
64
+ return "awaiting_capacity";
65
+ return c.status ?? "";
66
+ }
67
+
68
+ export function StudioActiveProcesses({
69
+ clips,
70
+ refresh,
71
+ }: {
72
+ clips: Clip[];
73
+ refresh: () => void;
74
+ }) {
75
+ const [cancelBusy, setCancelBusy] = useState<Set<string>>(new Set());
76
+ const [expanded, setExpanded] = useState<Set<string>>(new Set());
77
+ // A ~1s tick so the stall clock + "since last movement" advance live (independent
78
+ // of the 6s clip poll), without any fabricated progress.
79
+ const [nowMs, setNowMs] = useState(() => Date.now());
80
+ const mounted = useRef(true);
81
+ useEffect(() => {
82
+ mounted.current = true;
83
+ const iv = window.setInterval(() => setNowMs(Date.now()), 1000);
84
+ return () => {
85
+ mounted.current = false;
86
+ window.clearInterval(iv);
87
+ };
88
+ }, []);
89
+
90
+ const toggle = useCallback((jobId: string) => {
91
+ setExpanded((prev) => {
92
+ const n = new Set(prev);
93
+ if (n.has(jobId)) n.delete(jobId);
94
+ else n.add(jobId);
95
+ return n;
96
+ });
97
+ }, []);
98
+
99
+ const onCancel = useCallback(
100
+ async (jobId: string) => {
101
+ setCancelBusy((prev) => new Set(prev).add(jobId));
102
+ try {
103
+ await request<unknown>(jobCancelUrl(jobId), {
104
+ method: "POST",
105
+ meta: { specKey: "studio", operation: "studio.clip.cancel" },
106
+ });
107
+ if (!mounted.current) return;
108
+ refresh();
109
+ } finally {
110
+ if (mounted.current)
111
+ setCancelBusy((prev) => {
112
+ const n = new Set(prev);
113
+ n.delete(jobId);
114
+ return n;
115
+ });
116
+ }
117
+ },
118
+ [refresh],
119
+ );
120
+
121
+ const active = clips.filter((c) => IN_FLIGHT.has(c.status ?? ""));
122
+ // Queue position: Nth among QUEUED jobs (oldest first) — a client-side, honest index.
123
+ const queuePos = new Map<string, number>();
124
+ active
125
+ .filter((c) => (c.status ?? "") === "queued")
126
+ .slice()
127
+ .sort((a, b) => (a.created ?? 0) - (b.created ?? 0))
128
+ .forEach((c, i) => queuePos.set(c.job_id, i + 1));
129
+
130
+ return (
131
+ <div className="vi-studio-active">
132
+ <p className="vi-knob-headline">
133
+ Active renders{" "}
134
+ <span className="vi-knob-headline-sub">running &amp; queued studio jobs</span>
135
+ </p>
136
+ {active.length === 0 ? (
137
+ <p className="vi-knob-hint">No active renders — enqueue one and it appears here.</p>
138
+ ) : (
139
+ <ul className="vi-studio-active-list">
140
+ {active.map((c) => {
141
+ const phase = clipPhase(c);
142
+ const pos = queuePos.get(c.job_id);
143
+ const el = elapsed(c);
144
+ const pl = clipPlacement(c);
145
+ const line = placementLine(pl);
146
+ const gib = reservedGib(pl);
147
+ const external = isExternal(pl);
148
+ const phaseLabel =
149
+ phase === "awaiting_capacity" ? "awaiting capacity" : phase;
150
+ const isOpen = expanded.has(c.job_id);
151
+ const terminal = TERMINAL.has(c.status ?? "");
152
+ const stageLog = (c.stage_log ?? null) as StageEntry[] | null;
153
+ const failure = (c.failure ?? null) as JobFailure | null;
154
+ const stalled = isStalled(c.last_movement_ts, terminal, nowMs);
155
+ return (
156
+ <li key={c.job_id} className="vi-studio-active-row">
157
+ <div className="vi-studio-active-head">
158
+ <button
159
+ type="button"
160
+ className="vi-timeline-toggle"
161
+ onClick={() => toggle(c.job_id)}
162
+ aria-expanded={isOpen}
163
+ title={isOpen ? "Hide detail" : "Show what it's doing"}
164
+ >
165
+ <span className="vi-timeline-caret" aria-hidden>
166
+ {isOpen ? "▾" : "▸"}
167
+ </span>
168
+ </button>
169
+ <span
170
+ className={`vi-status vi-status-${phase}`}
171
+ title={
172
+ phase === "awaiting_capacity"
173
+ ? "Held — waiting for GPU capacity"
174
+ : undefined
175
+ }
176
+ >
177
+ {phaseLabel}
178
+ </span>
179
+ {stalled ? <span className="vi-timeline-stall-badge">stalled</span> : null}
180
+ <span className="vi-studio-active-id">{shortId(c.job_id)}</span>
181
+ </div>
182
+ <div className="vi-studio-active-meta">
183
+ {line ? (
184
+ <span
185
+ className={`vi-placement${external ? " vi-placement-external" : ""}`}
186
+ >
187
+ {external ? "⇄ " : ""}
188
+ {line}
189
+ {external ? " · external" : ""}
190
+ </span>
191
+ ) : null}
192
+ {c.current_stage ? <span>· {c.current_stage.replace(/_/g, " ")}</span> : null}
193
+ {gib ? <span>· {gib}</span> : null}
194
+ {pos != null ? <span>· queue #{pos}</span> : null}
195
+ {el ? <span>· {el}</span> : null}
196
+ </div>
197
+ {isOpen ? (
198
+ <div className="vi-timeline-panel">
199
+ <ProcessTimeline
200
+ stageLog={stageLog}
201
+ failure={failure}
202
+ currentStage={c.current_stage}
203
+ lastMovementTs={c.last_movement_ts}
204
+ terminal={terminal}
205
+ nowMs={nowMs}
206
+ />
207
+ </div>
208
+ ) : null}
209
+ <button
210
+ type="button"
211
+ className="vi-btn vi-btn-sm vi-btn-ghost"
212
+ onClick={() => void onCancel(c.job_id)}
213
+ disabled={cancelBusy.has(c.job_id)}
214
+ >
215
+ {cancelBusy.has(c.job_id) ? "Cancelling…" : "✕ Cancel"}
216
+ </button>
217
+ </li>
218
+ );
219
+ })}
220
+ </ul>
221
+ )}
222
+ </div>
223
+ );
224
+ }