@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.
- package/LICENSE +41 -0
- package/assets/sampels/images/bycycle/bd8603bad35d4f179f4d1134d8faf2ee.mp4 +0 -0
- package/assets/sampels/images/bycycle/frame_00000.png +0 -0
- package/assets/sampels/images/bycycle/frame_00001.png +0 -0
- package/assets/sampels/images/bycycle/frame_00002.png +0 -0
- package/assets/sampels/images/bycycle/frame_00003.png +0 -0
- package/assets/sampels/images/bycycle/frame_00004.png +0 -0
- package/assets/sampels/images/bycycle/frame_00005.png +0 -0
- package/assets/sampels/images/forrest/ff23090611b844a6b85c224f388bd542.mp4 +0 -0
- package/assets/sampels/images/forrest/frame_00000.png +0 -0
- package/assets/sampels/images/forrest/frame_00001.png +0 -0
- package/assets/sampels/images/forrest/frame_00002.png +0 -0
- package/assets/sampels/images/forrest/frame_00003.png +0 -0
- package/assets/sampels/images/forrest/frame_00004.png +0 -0
- package/assets/sampels/images/forrest/frame_00005.png +0 -0
- package/assets/sampels/images/shenzo/e1a4fcd623084ab4a986d11c8cd00db8.mp4 +0 -0
- package/assets/sampels/images/shenzo/frame_00000.png +0 -0
- package/assets/sampels/images/shenzo/frame_00001.png +0 -0
- package/assets/sampels/images/shenzo/frame_00002.png +0 -0
- package/assets/sampels/images/shenzo/frame_00003.png +0 -0
- package/assets/sampels/images/shenzo/frame_00004.png +0 -0
- package/assets/sampels/images/shenzo/frame_00005.png +0 -0
- package/assets/sampels/scenes/man.mp4 +0 -0
- package/assets/sampels/scenes/woman.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/_assembly/contrib_00.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/movie.json +67 -0
- package/assets/sampels/studio_movies/caverns/movie.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/manifest.json +87 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/provenance.json +7 -0
- package/assets/sampels/studio_movies/caverns/segment_01/branch.png +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/concat_list.txt +2 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_00.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_01.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/movie.json +81 -0
- package/assets/sampels/studio_movies/keeper-matrix/movie.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/manifest.json +87 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/provenance.json +7 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/branch.png +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/manifest.json +87 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/provenance.json +7 -0
- package/assets/sampels/videos/a_red_paper_boat_drifting_on_a_calm_pond__gentle_ripples__mo_a62d6c28.mp4 +0 -0
- package/assets/sampels/videos/aurora.mp4 +0 -0
- package/assets/sampels/videos/dark_night.mp4 +0 -0
- package/assets/sampels/videos/fire_and_soul.mp4 +0 -0
- package/assets/sampels/videos/harsh_seas.mp4 +0 -0
- package/entry.tsx +74 -0
- package/index.html +53 -0
- package/package.json +42 -0
- package/src/assets/hugpy-mark.png +0 -0
- package/src/config.ts +676 -0
- package/src/demo/DemoBanner.tsx +54 -0
- package/src/demo/assets/demo-audio.mp3 +0 -0
- package/src/demo/assets/demo-clip.mp4 +0 -0
- package/src/demo/assets/demo-crop.mp4 +0 -0
- package/src/demo/assets/demo-poster.jpg +0 -0
- package/src/demo/assets/frame-1.jpg +0 -0
- package/src/demo/assets/frame-2.jpg +0 -0
- package/src/demo/assets/frame-3.jpg +0 -0
- package/src/demo/assets/frame-4.jpg +0 -0
- package/src/demo/assets/frame-5.jpg +0 -0
- package/src/demo/assets/frame-6.jpg +0 -0
- package/src/demo/assets/gen-1.jpg +0 -0
- package/src/demo/assets/gen-2.jpg +0 -0
- package/src/demo/assets.d.ts +14 -0
- package/src/demo/demo.css +64 -0
- package/src/demo/demoFetch.ts +305 -0
- package/src/demo/fixtures.ts +814 -0
- package/src/demo/index.ts +43 -0
- package/src/demo/mode.ts +81 -0
- package/src/demo/promptAssist.ts +181 -0
- package/src/demo/seed.ts +97 -0
- package/src/nav/BrandMark.css +66 -0
- package/src/nav/BrandMark.tsx +40 -0
- package/src/nav/Navbar.css +13 -0
- package/src/nav/Navbar.tsx +68 -0
- package/src/regions/SpatialRegionEditor.tsx +375 -0
- package/src/regions/TemporalRegionEditor.tsx +485 -0
- package/src/regions/types.ts +85 -0
- package/src/session.ts +85 -0
- package/src/share.ts +77 -0
- package/src/stations/ActivePanel.tsx +55 -0
- package/src/stations/ActiveProcessesStation.tsx +272 -0
- package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +228 -0
- package/src/stations/AddFileBar.tsx +59 -0
- package/src/stations/AudioCropStation.tsx +157 -0
- package/src/stations/ComfyPanel.tsx +210 -0
- package/src/stations/ComfyStation.tsx +196 -0
- package/src/stations/CondensedKnobStrip.tsx +245 -0
- package/src/stations/ConsolePanel.tsx +82 -0
- package/src/stations/FrameExtractStation.tsx +148 -0
- package/src/stations/GenIdentityBar.tsx +412 -0
- package/src/stations/GenerateStation.tsx +5603 -0
- package/src/stations/GenerateStation.tsx.bak-tester-removal +5170 -0
- package/src/stations/GoalComposer.tsx +139 -0
- package/src/stations/ImageCropStation.tsx +153 -0
- package/src/stations/KnobInfo.tsx +64 -0
- package/src/stations/PlaceholderStation.tsx +26 -0
- package/src/stations/SectionTabs.tsx +276 -0
- package/src/stations/SharePanel.tsx +298 -0
- package/src/stations/SidebarRegistry.tsx +105 -0
- package/src/stations/StationShell.tsx +96 -0
- package/src/stations/StudioClipsStation.tsx +28 -0
- package/src/stations/WorkbenchDrawer.tsx +66 -0
- package/src/stations/WorkbenchSidebar.tsx +1224 -0
- package/src/stations/WorkbenchStation.tsx +91 -0
- package/src/stations/comfyEmbed.ts +79 -0
- package/src/stations/drawerShell.tsx +419 -0
- package/src/stations/ops/AudioCropCore.tsx +218 -0
- package/src/stations/ops/CharacterGroupsPanel.tsx +716 -0
- package/src/stations/ops/FrameExtractCore.tsx +695 -0
- package/src/stations/ops/ImageCropCore.tsx +164 -0
- package/src/stations/ops/VideoCropCore.tsx +193 -0
- package/src/stations/ops/useVideoCropJobs.ts +224 -0
- package/src/stations/pollCaps.ts +87 -0
- package/src/stations/registry.ts +191 -0
- package/src/stations/studio/AngleRingEditor.tsx +159 -0
- package/src/stations/studio/IdentitiesStation.tsx +2455 -0
- package/src/stations/studio/IdentityProfileControls.tsx +262 -0
- package/src/stations/studio/StudioActiveProcesses.tsx +224 -0
- package/src/stations/studio/StudioAssistLogPanel.tsx +220 -0
- package/src/stations/studio/StudioGenerateMode.tsx +29 -0
- package/src/stations/studio/StudioGenerateTab.tsx +2688 -0
- package/src/stations/studio/StudioItemCard.tsx +334 -0
- package/src/stations/studio/StudioLibraryTab.tsx +506 -0
- package/src/stations/studio/StudioMovieComposer.tsx +2875 -0
- package/src/stations/studio/StudioMovieComposer.tsx.bak-sessions-20260812 +2729 -0
- package/src/stations/studio/StudioMovieSessions.tsx +553 -0
- package/src/stations/studio/StudioMovieSessions.tsx.bak-sessions-20260812 +417 -0
- package/src/stations/studio/StudioPlane.tsx +302 -0
- package/src/stations/studio/StudioSplicedRow.tsx +476 -0
- package/src/stations/studio/StudioViewer.tsx +339 -0
- package/src/stations/studio/TurntableViewer.tsx +220 -0
- package/src/stations/studio/movieTimeline.ts +198 -0
- package/src/stations/studio/promptCard.tsx +498 -0
- package/src/stations/studio/studioItem.ts +87 -0
- package/src/stations/studio/studioShared.tsx +821 -0
- package/src/stations/studio/useIdentityProfiles.ts +575 -0
- package/src/stations/studio/useReconstruction.ts +283 -0
- package/src/stations/types.ts +43 -0
- package/src/stations/useAudioCropJobs.ts +202 -0
- package/src/stations/useAudioExtractJob.ts +144 -0
- package/src/stations/useCropJobs.ts +206 -0
- package/src/stations/useFrameJobs.ts +140 -0
- package/src/stations/useGenerateJob.ts +151 -0
- package/src/stations/useGenerateMovieJob.ts +177 -0
- package/src/stations/useGenerateRowJobs.ts +184 -0
- package/src/stations/useGenerateSceneJob.ts +156 -0
- package/src/stations/useGenerateSceneRowJobs.ts +282 -0
- package/src/style/app.css +4974 -0
- package/src/transport/client.ts +262 -0
- package/src/transport/telemetry.ts +97 -0
- package/src/video/DropReceptacle.tsx +122 -0
- package/src/video/PromptAssistButtons.tsx +118 -0
- package/src/video/composerBridge.ts +66 -0
- package/src/video/contract.ts +599 -0
- package/src/video/jobTracker.ts +702 -0
- package/src/video/mediaLibrary.ts +328 -0
- package/src/video/renderCompat.ts +512 -0
- package/src/video/sessionForm.ts +189 -0
- package/src/video/spreadAssist.ts +392 -0
- package/src/video/studioBridge.ts +82 -0
- package/src/video/useMediaJobs.ts +279 -0
- package/src/video/useModels.ts +413 -0
- package/src/video/useMoviePresets.ts +97 -0
- package/src/video/useMovieSessions.ts +292 -0
- package/src/video/usePresets.ts +167 -0
- package/src/video/useProjects.ts +87 -0
- package/src/video/usePromptAssist.ts +394 -0
- package/src/video/useRenderPresets.ts +221 -0
- package/src/video/useStudioAssistLog.ts +266 -0
- package/src/video/useStudioPresets.ts +128 -0
- package/src/video/useWorkerCalls.ts +165 -0
- package/tsconfig.json +17 -0
- package/vite.config.ts +33 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// STUDIO-ASSIST LIVE LOG — the client half (operator directive, 2026-07-31).
|
|
2
|
+
//
|
|
3
|
+
// One shared EventSource for the whole studio: the log panel (and any future
|
|
4
|
+
// mount) subscribes to THIS module rather than each opening its own stream.
|
|
5
|
+
// Structurally the video-arm twin of the console's evictionStream.js — a
|
|
6
|
+
// module-scope store, a reference-counted connection (opens on first subscriber,
|
|
7
|
+
// closes on last), a de-dup set spanning the backfill + the SSE replay window,
|
|
8
|
+
// and a rowid cursor tail so the stream is correct across gunicorn workers.
|
|
9
|
+
//
|
|
10
|
+
// It differs from evictionStream in one way: an assist attempt is logged as ONE
|
|
11
|
+
// terminal record (the generation choke point emits on failure; the mode handler
|
|
12
|
+
// emits served/parse_error on success — see comms/studio_assist_log.py), so the
|
|
13
|
+
// store is keyed by run_id and the newest record for a run_id wins. No folding of
|
|
14
|
+
// many stage-events into a card is needed; a row IS an attempt.
|
|
15
|
+
//
|
|
16
|
+
// Auth rides the same path every other /video call uses: a console operator's
|
|
17
|
+
// same-origin session cookie (credentials:"include" via the EventSource
|
|
18
|
+
// withCredentials flag) or a stored ?share= key appended to the stream URL (an
|
|
19
|
+
// EventSource cannot carry the X-Video-Share header the XHR transport uses).
|
|
20
|
+
import { useEffect, useState } from "react";
|
|
21
|
+
import { request, okValue } from "../transport/client";
|
|
22
|
+
import { hugpyConfig } from "../config";
|
|
23
|
+
import { videoShareKey } from "../share";
|
|
24
|
+
|
|
25
|
+
/** One stored generate attempt — the wire shape of comms/studio_assist_log.py. */
|
|
26
|
+
export interface AssistAttempt {
|
|
27
|
+
_id?: number;
|
|
28
|
+
ts?: number;
|
|
29
|
+
seq?: number;
|
|
30
|
+
worker_id?: string;
|
|
31
|
+
run_id?: string;
|
|
32
|
+
mode?: string;
|
|
33
|
+
kind?: string;
|
|
34
|
+
model_requested?: string;
|
|
35
|
+
model_resolved?: string;
|
|
36
|
+
/** The UNTRUNCATED model reply — the whole point of the log. */
|
|
37
|
+
raw?: string;
|
|
38
|
+
/** The think-stripped prose that would have been used as the prompt. */
|
|
39
|
+
text?: string;
|
|
40
|
+
/** The reasoning that was stripped out. */
|
|
41
|
+
reasoning?: string;
|
|
42
|
+
/** True when `text` was salvaged FROM the reasoning (no prose came back). */
|
|
43
|
+
from_reasoning?: boolean;
|
|
44
|
+
outcome?: "served" | "empty" | "parse_error" | "worker_error" | "resolve_error" | string;
|
|
45
|
+
error?: string;
|
|
46
|
+
elapsed_ms?: number;
|
|
47
|
+
/** The backfill route bounds a huge reply on the page load only. */
|
|
48
|
+
raw_truncated?: boolean;
|
|
49
|
+
/** Transport marker ("stream.ready") — never an attempt; filtered out. */
|
|
50
|
+
stage?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type ConnState = "idle" | "connecting" | "live" | "disconnected";
|
|
54
|
+
|
|
55
|
+
const MAX_ATTEMPTS = 300; // oldest attempts drop off the front
|
|
56
|
+
const BACKFILL_LIMIT = 300;
|
|
57
|
+
const RETRY_MS = 5000;
|
|
58
|
+
|
|
59
|
+
let attempts: AssistAttempt[] = []; // oldest-first
|
|
60
|
+
let conn: ConnState = "idle";
|
|
61
|
+
let error: string | null = null;
|
|
62
|
+
let version = 0;
|
|
63
|
+
|
|
64
|
+
const index = new Map<string, number>(); // run_id (or synthetic) -> position
|
|
65
|
+
const seenId = new Set<number>(); // de-dup by store rowid across backfill + replay
|
|
66
|
+
const listeners = new Set<() => void>();
|
|
67
|
+
|
|
68
|
+
let es: EventSource | null = null;
|
|
69
|
+
let refs = 0;
|
|
70
|
+
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
71
|
+
let backfilled = false;
|
|
72
|
+
|
|
73
|
+
function emit(): void {
|
|
74
|
+
version++;
|
|
75
|
+
for (const fn of listeners) {
|
|
76
|
+
try {
|
|
77
|
+
fn();
|
|
78
|
+
} catch {
|
|
79
|
+
/* one bad subscriber must not stall the rest */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function keyOf(rec: AssistAttempt): string {
|
|
85
|
+
return rec.run_id || `solo:${rec._id ?? rec.seq ?? rec.ts ?? Math.random()}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ingest(batch: AssistAttempt[]): void {
|
|
89
|
+
let changed = false;
|
|
90
|
+
for (const rec of batch) {
|
|
91
|
+
if (!rec || typeof rec !== "object") continue;
|
|
92
|
+
if (rec.stage === "stream.ready") continue; // transport marker, not an attempt
|
|
93
|
+
if (typeof rec._id === "number") {
|
|
94
|
+
if (seenId.has(rec._id)) continue;
|
|
95
|
+
seenId.add(rec._id);
|
|
96
|
+
}
|
|
97
|
+
const k = keyOf(rec);
|
|
98
|
+
const at = index.get(k);
|
|
99
|
+
if (at == null) {
|
|
100
|
+
index.set(k, attempts.length);
|
|
101
|
+
attempts = attempts.concat(rec);
|
|
102
|
+
} else {
|
|
103
|
+
// Newest record for a run_id wins (a terminal record supersedes any
|
|
104
|
+
// provisional one that arrived first).
|
|
105
|
+
const next = attempts.slice();
|
|
106
|
+
next[at] = { ...next[at], ...rec };
|
|
107
|
+
attempts = next;
|
|
108
|
+
}
|
|
109
|
+
changed = true;
|
|
110
|
+
}
|
|
111
|
+
if (!changed) return;
|
|
112
|
+
if (attempts.length > MAX_ATTEMPTS) {
|
|
113
|
+
attempts = attempts.slice(attempts.length - MAX_ATTEMPTS);
|
|
114
|
+
// Rebuild the index after trimming the front.
|
|
115
|
+
index.clear();
|
|
116
|
+
attempts.forEach((r, i) => index.set(keyOf(r), i));
|
|
117
|
+
}
|
|
118
|
+
emit();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function setConn(c: ConnState): void {
|
|
122
|
+
if (conn === c) return;
|
|
123
|
+
conn = c;
|
|
124
|
+
emit();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function streamUrl(): string {
|
|
128
|
+
const key = videoShareKey();
|
|
129
|
+
if (!key) return hugpyConfig.promptAssistLogStreamUrl;
|
|
130
|
+
const base = hugpyConfig.promptAssistLogStreamUrl;
|
|
131
|
+
const sep = base.includes("?") ? "&" : "?";
|
|
132
|
+
return `${base}${sep}share=${encodeURIComponent(key)}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function openStream(): void {
|
|
136
|
+
if (es) return;
|
|
137
|
+
setConn("connecting");
|
|
138
|
+
let source: EventSource;
|
|
139
|
+
try {
|
|
140
|
+
source = new EventSource(streamUrl(), { withCredentials: true });
|
|
141
|
+
} catch {
|
|
142
|
+
setConn("disconnected");
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
es = source;
|
|
146
|
+
source.onopen = () => setConn("live");
|
|
147
|
+
source.onmessage = (e: MessageEvent) => {
|
|
148
|
+
let rec: AssistAttempt;
|
|
149
|
+
try {
|
|
150
|
+
rec = JSON.parse(e.data);
|
|
151
|
+
} catch {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
ingest([rec]);
|
|
155
|
+
};
|
|
156
|
+
source.onerror = () => {
|
|
157
|
+
source.close();
|
|
158
|
+
if (es === source) es = null;
|
|
159
|
+
setConn("disconnected");
|
|
160
|
+
// Reconnect only while someone is still watching. The server caps a stream at
|
|
161
|
+
// an hour and expects re-attach; the replay window + de-dup set mean no gap.
|
|
162
|
+
if (refs > 0) {
|
|
163
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
164
|
+
retryTimer = setTimeout(() => {
|
|
165
|
+
if (refs > 0) openStream();
|
|
166
|
+
}, RETRY_MS);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function closeStream(): void {
|
|
172
|
+
if (retryTimer) {
|
|
173
|
+
clearTimeout(retryTimer);
|
|
174
|
+
retryTimer = null;
|
|
175
|
+
}
|
|
176
|
+
if (es) {
|
|
177
|
+
es.close();
|
|
178
|
+
es = null;
|
|
179
|
+
}
|
|
180
|
+
setConn("idle");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function backfillOnce(): void {
|
|
184
|
+
if (backfilled) return;
|
|
185
|
+
backfilled = true;
|
|
186
|
+
request<{ events?: AssistAttempt[] }>(
|
|
187
|
+
`${hugpyConfig.promptAssistLogUrl}?limit=${BACKFILL_LIMIT}`,
|
|
188
|
+
{ method: "GET", meta: { specKey: "studio", operation: "assist-log-backfill" } },
|
|
189
|
+
)
|
|
190
|
+
.then((res) => {
|
|
191
|
+
if (res.ok) {
|
|
192
|
+
error = null;
|
|
193
|
+
const value = okValue(res);
|
|
194
|
+
ingest(Array.isArray(value?.events) ? value.events : []);
|
|
195
|
+
emit();
|
|
196
|
+
} else {
|
|
197
|
+
error = "could not load the assist log";
|
|
198
|
+
emit();
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
.catch(() => {
|
|
202
|
+
error = "could not load the assist log";
|
|
203
|
+
emit();
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function acquire(): void {
|
|
208
|
+
refs++;
|
|
209
|
+
if (refs === 1) {
|
|
210
|
+
backfillOnce();
|
|
211
|
+
openStream();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function release(): void {
|
|
216
|
+
refs = Math.max(0, refs - 1);
|
|
217
|
+
if (refs === 0) closeStream();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Force a reconnect (the panel's Reconnect button). */
|
|
221
|
+
export function reconnectAssistLog(): void {
|
|
222
|
+
closeStream();
|
|
223
|
+
if (refs > 0) openStream();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface UseStudioAssistLog {
|
|
227
|
+
attempts: AssistAttempt[];
|
|
228
|
+
conn: ConnState;
|
|
229
|
+
error: string | null;
|
|
230
|
+
reconnect: () => void;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Subscribe to the shared studio-assist log stream. */
|
|
234
|
+
export function useStudioAssistLog(): UseStudioAssistLog {
|
|
235
|
+
const [snap, setSnap] = useState(() => ({ attempts, conn, error, version }));
|
|
236
|
+
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
acquire();
|
|
239
|
+
const onChange = () => setSnap({ attempts, conn, error, version });
|
|
240
|
+
onChange();
|
|
241
|
+
listeners.add(onChange);
|
|
242
|
+
return () => {
|
|
243
|
+
listeners.delete(onChange);
|
|
244
|
+
release();
|
|
245
|
+
};
|
|
246
|
+
}, []);
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
attempts: snap.attempts,
|
|
250
|
+
conn: snap.conn,
|
|
251
|
+
error: snap.error,
|
|
252
|
+
reconnect: reconnectAssistLog,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Test/diagnostic reset — drops everything and detaches. */
|
|
257
|
+
export function _resetForTests(): void {
|
|
258
|
+
closeStream();
|
|
259
|
+
attempts = [];
|
|
260
|
+
index.clear();
|
|
261
|
+
seenId.clear();
|
|
262
|
+
listeners.clear();
|
|
263
|
+
refs = 0;
|
|
264
|
+
backfilled = false;
|
|
265
|
+
error = null;
|
|
266
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// The Studio Clips station's PRESET dropdown source. Fetches the curated studio
|
|
2
|
+
// clip presets (GET /video/studio/presets), validates them with a tolerant schema,
|
|
3
|
+
// and exposes the list + loading/error as data (never throws) — the same
|
|
4
|
+
// data-not-throws contract as useMoviePresets.ts, which it is cloned from.
|
|
5
|
+
//
|
|
6
|
+
// Two deliberate parities with useMoviePresets:
|
|
7
|
+
// 1. The wire is the {presets:[…]} envelope (matches /video/presets + /movie/presets).
|
|
8
|
+
// 2. The fetch is GATED behind an `active` flag so a caller can keep it idle until
|
|
9
|
+
// the station is entered (and it stays cached after). The Studio Clips station
|
|
10
|
+
// passes active=true (it only mounts when its tab is open).
|
|
11
|
+
//
|
|
12
|
+
// A studio preset pins a CAPABILITY ("i2v"/"t2v") + geometry + a routing
|
|
13
|
+
// `vram_budget_gb` (NOT a model_key — the studio router picks the model); picking one
|
|
14
|
+
// prefills the generate affordance and drives its enqueue.
|
|
15
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import { request, okValue, errorOf, describeAppError } from "../transport/client";
|
|
18
|
+
import { hugpyConfig } from "../config";
|
|
19
|
+
|
|
20
|
+
// Tolerant/passthrough — assert only the fields the station reads; a preset may omit
|
|
21
|
+
// the advisory ones (the station keeps its current value). Kept INLINE (like
|
|
22
|
+
// StudioClipsStation's clipSchema) so the frozen contract module stays untouched.
|
|
23
|
+
export const studioPresetSchema = z
|
|
24
|
+
.object({
|
|
25
|
+
id: z.string(),
|
|
26
|
+
name: z.string(),
|
|
27
|
+
description: z.string().nullable().optional(),
|
|
28
|
+
capability: z.string(),
|
|
29
|
+
width: z.number(),
|
|
30
|
+
height: z.number(),
|
|
31
|
+
fps: z.number(),
|
|
32
|
+
vram_budget_gb: z.number().nullable().optional(),
|
|
33
|
+
seed: z.number().nullable().optional(),
|
|
34
|
+
prompt: z.string().nullable().optional(),
|
|
35
|
+
negative: z.string().nullable().optional(),
|
|
36
|
+
recommended: z.string().nullable().optional(),
|
|
37
|
+
// Slice (a) / v2v: a restyle preset (capability "v2v") is a video TRANSFORM — it
|
|
38
|
+
// needs a staged source clip to mean anything. Drives the station's restyle flow.
|
|
39
|
+
requires_source: z.boolean().nullable().optional(),
|
|
40
|
+
// Identity lock (capability "id_lock"): the preset needs ≥1 REFERENCE image (the
|
|
41
|
+
// subject to lock). Exact sibling of requires_source — same consumption pattern;
|
|
42
|
+
// the surface gates the id_lock reference-slot flow on it (and on the capability).
|
|
43
|
+
requires_reference: z.boolean().nullable().optional(),
|
|
44
|
+
// UI-honesty badge: a note about the prompt for this preset. Set on SYNTHETIC
|
|
45
|
+
// previews to state the prompt is recorded but NOT rendered (frames = seed +
|
|
46
|
+
// geometry), so an evocative scaffold never misleads. "" / absent = no badge.
|
|
47
|
+
prompt_note: z.string().nullable().optional(),
|
|
48
|
+
})
|
|
49
|
+
.passthrough();
|
|
50
|
+
export type StudioPreset = z.infer<typeof studioPresetSchema>;
|
|
51
|
+
|
|
52
|
+
// GET /video/studio/presets returns a {presets:[…]} envelope (matches /video/presets).
|
|
53
|
+
const studioPresetsResponseSchema = z.object({ presets: z.array(studioPresetSchema) });
|
|
54
|
+
|
|
55
|
+
export interface StudioPresetsState {
|
|
56
|
+
presets: StudioPreset[];
|
|
57
|
+
loading: boolean;
|
|
58
|
+
error: string | null;
|
|
59
|
+
/** Force an immediate re-fetch of the studio-preset list. */
|
|
60
|
+
refresh: () => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Load the curated studio clip presets. `active` gates the network call; while
|
|
65
|
+
* inactive the hook reports loading:false / an empty list (nothing to show).
|
|
66
|
+
*/
|
|
67
|
+
export function useStudioPresets(active: boolean): StudioPresetsState {
|
|
68
|
+
const [state, setState] = useState<Omit<StudioPresetsState, "refresh">>({
|
|
69
|
+
presets: [],
|
|
70
|
+
loading: false,
|
|
71
|
+
error: null,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const mounted = useRef(true);
|
|
75
|
+
const inFlight = useRef(false);
|
|
76
|
+
const loadedRef = useRef(false);
|
|
77
|
+
const ctrlRef = useRef<AbortController | null>(null);
|
|
78
|
+
|
|
79
|
+
const load = useCallback(async (background: boolean) => {
|
|
80
|
+
if (inFlight.current) return;
|
|
81
|
+
inFlight.current = true;
|
|
82
|
+
if (!background) setState((s) => ({ ...s, loading: true }));
|
|
83
|
+
const signal = ctrlRef.current?.signal;
|
|
84
|
+
try {
|
|
85
|
+
const res = await request<unknown>(hugpyConfig.studioPresetsUrl, {
|
|
86
|
+
signal,
|
|
87
|
+
meta: { specKey: "studio", operation: "studio.presets.list" },
|
|
88
|
+
});
|
|
89
|
+
if (!mounted.current) return;
|
|
90
|
+
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
// A background refresh that fails must not wipe a working picker.
|
|
93
|
+
if (background) return;
|
|
94
|
+
setState({ presets: [], loading: false, error: describeAppError(errorOf(res)) });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const parsed = studioPresetsResponseSchema.safeParse(okValue(res));
|
|
98
|
+
if (!parsed.success) {
|
|
99
|
+
if (background) return;
|
|
100
|
+
setState({ presets: [], loading: false, error: "Malformed studio presets response." });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
loadedRef.current = true;
|
|
104
|
+
setState({ presets: parsed.data.presets, loading: false, error: null });
|
|
105
|
+
} finally {
|
|
106
|
+
inFlight.current = false;
|
|
107
|
+
}
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
// Load once, the first time the station becomes active. Stays cached after.
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
mounted.current = true;
|
|
113
|
+
if (!active || loadedRef.current) return;
|
|
114
|
+
const ctrl = new AbortController();
|
|
115
|
+
ctrlRef.current = ctrl;
|
|
116
|
+
void load(false);
|
|
117
|
+
return () => {
|
|
118
|
+
mounted.current = false;
|
|
119
|
+
ctrl.abort();
|
|
120
|
+
};
|
|
121
|
+
}, [active, load]);
|
|
122
|
+
|
|
123
|
+
const refresh = useCallback(() => {
|
|
124
|
+
void load(loadedRef.current);
|
|
125
|
+
}, [load]);
|
|
126
|
+
|
|
127
|
+
return { ...state, refresh };
|
|
128
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// A live view of EVERY in-flight worker call across ALL transports (web / v1 /
|
|
2
|
+
// discord / cli / media) — the fleet-wide feed the session jobTracker CANNOT see,
|
|
3
|
+
// because the tracker only knows jobs THIS browser enqueued. GET /llm/jobs is the
|
|
4
|
+
// authoritative unified feed; this hook polls it, keeps the in-flight rows
|
|
5
|
+
// (pending / processing / streaming), drops `download` provisioning (shown
|
|
6
|
+
// elsewhere, and there can be dozens), and hands the survivors to the shared Active
|
|
7
|
+
// Processes panel so an identity reconstruction (kind:"identity_reconstruction",
|
|
8
|
+
// transport:"media") — or any render from another station — shows up even though
|
|
9
|
+
// this browser never launched it.
|
|
10
|
+
//
|
|
11
|
+
// Cadence ~2s (the panel doesn't need the tracker's 800ms poll); a fetch error
|
|
12
|
+
// keeps the last-good list rather than flashing empty. Mounted-ref guarded so a
|
|
13
|
+
// late poll never setState after unmount, and the interval is cleared on unmount.
|
|
14
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
15
|
+
import { hugpyConfig } from "../config";
|
|
16
|
+
import { request, okValue } from "../transport/client";
|
|
17
|
+
|
|
18
|
+
/** Poll cadence for the unified worker-call feed. */
|
|
19
|
+
const POLL_MS = 2000;
|
|
20
|
+
|
|
21
|
+
/** In-flight statuses from the /llm/jobs feed (everything else is terminal/idle). */
|
|
22
|
+
const IN_FLIGHT = new Set(["pending", "processing", "streaming"]);
|
|
23
|
+
|
|
24
|
+
/** One in-flight worker call, projected from a /llm/jobs row for the panel. */
|
|
25
|
+
export interface WorkerCall {
|
|
26
|
+
/** Server job id — the cancel handle + the dedup key against a TrackedJob.jobId. */
|
|
27
|
+
id: string;
|
|
28
|
+
/** Raw job kind (humanized for display in the row). */
|
|
29
|
+
kind: string;
|
|
30
|
+
/** In-flight status: pending | processing | streaming. */
|
|
31
|
+
status: string;
|
|
32
|
+
/** Transport that launched it — decides the cancel endpoint (media vs llm chat). */
|
|
33
|
+
transport: string;
|
|
34
|
+
/** Model label if the feed carries one (model_name preferred over model). */
|
|
35
|
+
model: string | null;
|
|
36
|
+
/** Serving worker name if present. */
|
|
37
|
+
worker: string | null;
|
|
38
|
+
/**
|
|
39
|
+
* ms-epoch start anchor, derived from the server `elapsed` seconds at poll time —
|
|
40
|
+
* the row's live clock reads (now - startedAt), so the elapsed ticks between polls
|
|
41
|
+
* and re-aligns to the server's truth on every refresh.
|
|
42
|
+
*/
|
|
43
|
+
startedAt: number;
|
|
44
|
+
/**
|
|
45
|
+
* Fractional progress 0..1 if the feed carries it, else null (unknown → the row
|
|
46
|
+
* shows an indeterminate "working" bar rather than a fabricated percentage).
|
|
47
|
+
*/
|
|
48
|
+
progress: number | null;
|
|
49
|
+
/** Free-text status line the worker is currently emitting (if any). */
|
|
50
|
+
message: string | null;
|
|
51
|
+
/** Coarse pipeline stage label (e.g. "sampling", "encoding") if the feed carries it. */
|
|
52
|
+
stage: string | null;
|
|
53
|
+
/** Last ≤40 log lines, NEWEST LAST — the scrolling tail in the expanded panel. */
|
|
54
|
+
logTail: string[];
|
|
55
|
+
/** Server-asserted wedged flag: the render is not making progress. */
|
|
56
|
+
stalled: boolean;
|
|
57
|
+
/** True once a cancel was requested and the server acknowledged it. */
|
|
58
|
+
cancelRequested: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* ms-epoch of the last observed movement (server `updated_at`), or null. The row
|
|
61
|
+
* derives "last movement Ns ago" as (now - updatedAt) and flips to a stalled look
|
|
62
|
+
* when that exceeds the stall window, so a frozen render stops reading as live.
|
|
63
|
+
*/
|
|
64
|
+
updatedAt: number | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function str(v: unknown): string | null {
|
|
68
|
+
return typeof v === "string" && v.trim() ? v : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Clamp an arbitrary progress reading into the renderable 0..1 band. */
|
|
72
|
+
function clamp01(v: number): number {
|
|
73
|
+
return v < 0 ? 0 : v > 1 ? 1 : v;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A server timestamp → ms-epoch. Tolerates epoch SECONDS (the arm's usual wire
|
|
77
|
+
// form, ~1.7e9), epoch MILLISECONDS (~1.7e12), and ISO strings; anything else → null.
|
|
78
|
+
function toMs(v: unknown): number | null {
|
|
79
|
+
if (typeof v === "number" && isFinite(v)) return v < 1e12 ? v * 1000 : v;
|
|
80
|
+
if (typeof v === "string" && v.trim()) {
|
|
81
|
+
const t = Date.parse(v);
|
|
82
|
+
return isFinite(t) ? t : null;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Project the /llm/jobs envelope into the in-flight WorkerCall rows. Defensive by
|
|
88
|
+
// construction (no zod schema for this feed, and strictNullChecks is off in the
|
|
89
|
+
// arm's tsconfig): a malformed row is skipped, never thrown.
|
|
90
|
+
function parseJobs(raw: unknown, at: number): WorkerCall[] {
|
|
91
|
+
const jobs = (raw as { jobs?: unknown } | null)?.jobs;
|
|
92
|
+
if (!Array.isArray(jobs)) return [];
|
|
93
|
+
const out: WorkerCall[] = [];
|
|
94
|
+
for (const j of jobs) {
|
|
95
|
+
if (!j || typeof j !== "object") continue;
|
|
96
|
+
const row = j as Record<string, unknown>;
|
|
97
|
+
const id = str(row.id);
|
|
98
|
+
if (!id) continue;
|
|
99
|
+
const kind = str(row.kind) ?? "";
|
|
100
|
+
if (kind === "download") continue; // provisioning — shown elsewhere
|
|
101
|
+
const status = str(row.status) ?? "";
|
|
102
|
+
if (!IN_FLIGHT.has(status)) continue;
|
|
103
|
+
const elapsed =
|
|
104
|
+
typeof row.elapsed === "number" && isFinite(row.elapsed) ? row.elapsed : 0;
|
|
105
|
+
// NEW fields (a parallel backend change adds progress/stage/log_tail; until it
|
|
106
|
+
// ships these read absent) — all optional, projected defensively so a bare
|
|
107
|
+
// legacy row still renders, just without the richer expanded detail.
|
|
108
|
+
const progress =
|
|
109
|
+
typeof row.progress === "number" && isFinite(row.progress)
|
|
110
|
+
? clamp01(row.progress)
|
|
111
|
+
: null;
|
|
112
|
+
const logTail = Array.isArray(row.log_tail)
|
|
113
|
+
? row.log_tail.filter((l): l is string => typeof l === "string").slice(-40)
|
|
114
|
+
: [];
|
|
115
|
+
out.push({
|
|
116
|
+
id,
|
|
117
|
+
kind,
|
|
118
|
+
status,
|
|
119
|
+
transport: str(row.transport) ?? "",
|
|
120
|
+
model: str(row.model_name) ?? str(row.model),
|
|
121
|
+
worker: str(row.worker),
|
|
122
|
+
startedAt: at - Math.max(0, elapsed) * 1000,
|
|
123
|
+
progress,
|
|
124
|
+
message: str(row.message),
|
|
125
|
+
stage: str(row.stage),
|
|
126
|
+
logTail,
|
|
127
|
+
stalled: row.stalled === true,
|
|
128
|
+
cancelRequested: row.cancel_requested === true,
|
|
129
|
+
updatedAt: toMs(row.updated_at),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The live in-flight worker calls + a manual `refetch` (fired right after a cancel,
|
|
137
|
+
* so the row disappears without waiting for the next poll tick). Polls only while a
|
|
138
|
+
* consumer is mounted — the panel mounts it only on the Active Processes tab, so no
|
|
139
|
+
* background polling when that tab is closed.
|
|
140
|
+
*/
|
|
141
|
+
export function useWorkerCalls(): { calls: WorkerCall[]; refetch: () => void } {
|
|
142
|
+
const [calls, setCalls] = useState<WorkerCall[]>([]);
|
|
143
|
+
const mounted = useRef(true);
|
|
144
|
+
|
|
145
|
+
const load = useCallback(async () => {
|
|
146
|
+
const r = await request<unknown>(hugpyConfig.llmJobsUrl, {
|
|
147
|
+
meta: { specKey: "workers", operation: "worker.calls.poll" },
|
|
148
|
+
});
|
|
149
|
+
if (!mounted.current) return;
|
|
150
|
+
if (!r.ok) return; // transient — keep last-good, retry next tick
|
|
151
|
+
setCalls(parseJobs(okValue(r), Date.now()));
|
|
152
|
+
}, []);
|
|
153
|
+
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
mounted.current = true;
|
|
156
|
+
void load();
|
|
157
|
+
const id = window.setInterval(() => void load(), POLL_MS);
|
|
158
|
+
return () => {
|
|
159
|
+
mounted.current = false;
|
|
160
|
+
window.clearInterval(id);
|
|
161
|
+
};
|
|
162
|
+
}, [load]);
|
|
163
|
+
|
|
164
|
+
return { calls, refetch: load };
|
|
165
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"jsx": "react-jsx",
|
|
7
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"strict": false,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"allowSyntheticDefaultImports": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"noEmit": true
|
|
15
|
+
},
|
|
16
|
+
"include": ["src", "entry.tsx"]
|
|
17
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import react from "@vitejs/plugin-react";
|
|
3
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
4
|
+
|
|
5
|
+
// Standalone build of the video-intelligence "arm" of the hugpy ecosystem —
|
|
6
|
+
// sibling of media_intelligence_ui with the same posture:
|
|
7
|
+
// - Served at /video by the hugpy dev UI's origin, so `base` makes every asset
|
|
8
|
+
// URL resolve under that path.
|
|
9
|
+
// - Native toolchain (Vite + Tailwind v4), deliberately NOT integrated into the
|
|
10
|
+
// dev UI's webpack build.
|
|
11
|
+
// - Dials the SAME-ORIGIN current-hugpy API (/api) — see src/config.ts. Override
|
|
12
|
+
// the base with VITE_HUGPY_API_BASE at build time if needed.
|
|
13
|
+
export default defineConfig({
|
|
14
|
+
base: "/video/",
|
|
15
|
+
plugins: [react(), tailwindcss()],
|
|
16
|
+
build: {
|
|
17
|
+
outDir: "dist",
|
|
18
|
+
emptyOutDir: true,
|
|
19
|
+
},
|
|
20
|
+
// Standalone `vite dev` / `vite preview` reach the same-origin API by proxying
|
|
21
|
+
// /api to the local hugpy Flask backend, stripping the /api prefix exactly like
|
|
22
|
+
// the webpack devServer (dev/ui) and prod nginx do. Override the target with
|
|
23
|
+
// HUGPY_API_URL.
|
|
24
|
+
server: {
|
|
25
|
+
proxy: {
|
|
26
|
+
"/api": {
|
|
27
|
+
target: process.env.HUGPY_API_URL || "http://127.0.0.1:7002",
|
|
28
|
+
changeOrigin: true,
|
|
29
|
+
rewrite: (p) => p.replace(/^\/api/, ""),
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
});
|