@hugpy/video-intelligence-ui 0.3.3 → 0.3.5
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/dist/assets/index-C_av7KDW.js +4190 -0
- package/dist/assets/index-DMoT8G1q.css +1 -0
- package/dist/index.html +2 -2
- package/entry.tsx +6 -27
- package/package.json +3 -2
- package/src/config.ts +11 -27
- package/src/demo/demoFetch.ts +0 -9
- package/src/nav/Navbar.tsx +3 -3
- package/src/session.ts +8 -43
- package/src/stations/GenerateStation.tsx +8 -5
- package/src/stations/SharePanel.tsx +4 -4
- package/src/stations/ToksPanel.tsx +1 -2
- package/src/stations/WorkbenchSidebar.tsx +8 -261
- package/src/stations/ops/CharacterGroupsPanel.tsx +75 -83
- package/src/stations/studio/IdentitiesStation.tsx +66 -75
- package/src/stations/studio/StudioGenerateMode.tsx +3 -2
- package/src/stations/studio/StudioGenerateTab.tsx +258 -188
- package/src/stations/studio/StudioMovieComposer.tsx +320 -57
- package/src/stations/studio/StudioPlane.tsx +3 -1
- package/src/stations/studio/StudioViewer.tsx +6 -11
- package/src/stations/studio/studioShared.tsx +14 -2
- package/src/stations/studio/useIdentityCreateJob.ts +26 -21
- package/src/stations/studio/useReconstruction.ts +11 -5
- package/src/video/PromptAssistButtons.tsx +5 -6
- package/src/video/jobStream.ts +119 -0
- package/src/video/jobTracker.ts +67 -76
- package/src/video/modelsFeed.ts +105 -0
- package/src/video/studioOracle.ts +48 -0
- package/src/video/useMediaJobs.ts +17 -3
- package/src/video/useModels.ts +12 -53
- package/src/video/useMovieSessions.ts +15 -6
- package/src/video/useOperatorHistory.ts +11 -101
- package/src/video/usePromptAssist.ts +6 -3
- package/src/video/useToks.ts +14 -6
- package/src/video/watchVideoJob.ts +43 -0
- package/dist/assets/index-DuT1AMLn.css +0 -1
- package/dist/assets/index-_13ZiBDP.js +0 -4191
- package/src/video/useWorkerCalls.ts +0 -165
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// SHARED model catalog feed (2026-10-05) — the models twin of jobStream.ts.
|
|
2
|
+
// Every model picker on the page SEEDS from ONE shared GET of /v1/models and
|
|
3
|
+
// /prompt/tasks (the first hook to ask fetches; the rest reuse that answer), then
|
|
4
|
+
// subscribes here. One EventSource on /api/llm/models/rows/stream (core pushes a
|
|
5
|
+
// row the moment a box's `models` / `residents` row changes — DB trigger ->
|
|
6
|
+
// pg_notify) marks the cached answer stale and tells subscribers to re-read it.
|
|
7
|
+
// No focus/visibility revalidation and no timer: the catalog moves only when the
|
|
8
|
+
// DB says it moved. Same reconnect rule as jobStream: re-attach only after a
|
|
9
|
+
// stream that HAD opened drops (the server caps it at an hour).
|
|
10
|
+
import { hugpyConfig } from "../config";
|
|
11
|
+
import { request } from "../transport/client";
|
|
12
|
+
|
|
13
|
+
type Result = Awaited<ReturnType<typeof request<unknown>>>;
|
|
14
|
+
type Listener = () => void;
|
|
15
|
+
|
|
16
|
+
const cache = new Map<string, Promise<Result>>();
|
|
17
|
+
const listeners = new Set<Listener>();
|
|
18
|
+
let es: EventSource | null = null;
|
|
19
|
+
let refs = 0;
|
|
20
|
+
let everOpened = false;
|
|
21
|
+
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
22
|
+
let notifyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
23
|
+
const RETRY_MS = 5000;
|
|
24
|
+
const COALESCE_MS = 500; // one action touches several rows; re-read once
|
|
25
|
+
|
|
26
|
+
/** The shared answer for `url` (one request for the whole page until the DB
|
|
27
|
+
* reports a change). A failed answer is not kept, so the next caller retries. */
|
|
28
|
+
export function sharedRequest(url: string, meta: Record<string, string>): Promise<Result> {
|
|
29
|
+
let p = cache.get(url);
|
|
30
|
+
if (!p) {
|
|
31
|
+
p = request<unknown>(url, { meta } as Parameters<typeof request>[1]);
|
|
32
|
+
cache.set(url, p);
|
|
33
|
+
p.then((r) => {
|
|
34
|
+
if (!r.ok && cache.get(url) === p) cache.delete(url);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return p;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function changed(): void {
|
|
41
|
+
if (notifyTimer) return;
|
|
42
|
+
notifyTimer = setTimeout(() => {
|
|
43
|
+
notifyTimer = null;
|
|
44
|
+
cache.clear();
|
|
45
|
+
for (const fn of listeners) {
|
|
46
|
+
try {
|
|
47
|
+
fn();
|
|
48
|
+
} catch {
|
|
49
|
+
/* one bad subscriber must not stall the rest */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}, COALESCE_MS);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function openStream(): void {
|
|
56
|
+
if (es || typeof window === "undefined" || typeof EventSource === "undefined") return;
|
|
57
|
+
let source: EventSource;
|
|
58
|
+
try {
|
|
59
|
+
source = new EventSource(`${hugpyConfig.apiBase}/llm/models/rows/stream`, {
|
|
60
|
+
withCredentials: true,
|
|
61
|
+
});
|
|
62
|
+
} catch {
|
|
63
|
+
return; // no EventSource — pickers keep their one-time seed
|
|
64
|
+
}
|
|
65
|
+
es = source;
|
|
66
|
+
source.onopen = () => {
|
|
67
|
+
everOpened = true;
|
|
68
|
+
};
|
|
69
|
+
source.onmessage = (e: MessageEvent) => {
|
|
70
|
+
let ev: { stage?: string };
|
|
71
|
+
try {
|
|
72
|
+
ev = JSON.parse(e.data);
|
|
73
|
+
} catch {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (ev.stage === "stream.ready") return; // transport hello, not a row
|
|
77
|
+
changed();
|
|
78
|
+
};
|
|
79
|
+
source.onerror = () => {
|
|
80
|
+
source.close();
|
|
81
|
+
es = null;
|
|
82
|
+
if (!everOpened || refs === 0 || retryTimer) return; // absent endpoint: stay on the seed
|
|
83
|
+
retryTimer = setTimeout(() => {
|
|
84
|
+
retryTimer = null;
|
|
85
|
+
if (refs > 0) openStream();
|
|
86
|
+
}, RETRY_MS);
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Call `fn` whenever the DB reports a model-row change. Returns the unsubscribe. */
|
|
91
|
+
export function subscribeModelChanges(fn: Listener): () => void {
|
|
92
|
+
listeners.add(fn);
|
|
93
|
+
refs += 1;
|
|
94
|
+
openStream();
|
|
95
|
+
return () => {
|
|
96
|
+
listeners.delete(fn);
|
|
97
|
+
refs -= 1;
|
|
98
|
+
if (refs <= 0) {
|
|
99
|
+
refs = 0;
|
|
100
|
+
es?.close();
|
|
101
|
+
es = null;
|
|
102
|
+
everOpened = false;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { hugpyConfig } from "../config";
|
|
2
|
+
import { request, okValue, errorOf, describeAppError } from "../transport/client";
|
|
3
|
+
|
|
4
|
+
export type OracleSettings = {
|
|
5
|
+
model_id?: string;
|
|
6
|
+
width?: number;
|
|
7
|
+
height?: number;
|
|
8
|
+
fps?: number;
|
|
9
|
+
frames?: number;
|
|
10
|
+
steps?: number;
|
|
11
|
+
cfg?: number;
|
|
12
|
+
seed?: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export async function planStudioSettings(
|
|
16
|
+
capability: "t2v" | "i2v",
|
|
17
|
+
prompt: string,
|
|
18
|
+
settings: OracleSettings,
|
|
19
|
+
explicit: string[],
|
|
20
|
+
): Promise<OracleSettings> {
|
|
21
|
+
const res = await request<unknown>(hugpyConfig.studioPlanUrl, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: { "Content-Type": "application/json" },
|
|
24
|
+
body: JSON.stringify({ capability, prompt, settings, explicit_settings: explicit }),
|
|
25
|
+
meta: { specKey: "studio", operation: "studio.oracle.plan" },
|
|
26
|
+
});
|
|
27
|
+
if (!res.ok) throw new Error(describeAppError(errorOf(res)));
|
|
28
|
+
const result = okValue(res) as { settings?: OracleSettings };
|
|
29
|
+
if (!result || !result.settings || !result.settings.model_id) {
|
|
30
|
+
throw new Error("Oracle returned no executable Studio settings plan.");
|
|
31
|
+
}
|
|
32
|
+
return result.settings;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function planStudioMovie(body: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
36
|
+
const res = await request<unknown>(hugpyConfig.studioPlanUrl, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: { "Content-Type": "application/json" },
|
|
39
|
+
body: JSON.stringify(body),
|
|
40
|
+
meta: { specKey: "studio", operation: "studio.oracle.movie.plan" },
|
|
41
|
+
});
|
|
42
|
+
if (!res.ok) throw new Error(describeAppError(errorOf(res)));
|
|
43
|
+
const result = okValue(res) as { body?: Record<string, unknown>; planning?: unknown[] };
|
|
44
|
+
if (!result || !result.body || !Array.isArray(result.planning)) {
|
|
45
|
+
throw new Error("Oracle returned no executable Cinema plan.");
|
|
46
|
+
}
|
|
47
|
+
return result.body;
|
|
48
|
+
}
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
13
13
|
import { hugpyConfig } from "../config";
|
|
14
14
|
import { request, okValue } from "../transport/client";
|
|
15
|
+
import { subscribeJobStream } from "./jobStream";
|
|
15
16
|
// The stage-timeline + failure shapes are the SAME wire objects the studio tab
|
|
16
17
|
// already renders through `ProcessTimeline`; reusing its types (and, in the panel,
|
|
17
18
|
// the component itself) keeps one renderer for "what it did / where it broke"
|
|
18
19
|
// instead of a second, divergent one for the console-wide view.
|
|
19
20
|
import type { StageEntry, JobFailure } from "../stations/studio/studioShared";
|
|
20
21
|
|
|
21
|
-
const POLL_MS = 2000;
|
|
22
22
|
const IN_FLIGHT = new Set(["queued", "claimed", "running", "cancelling"]);
|
|
23
23
|
|
|
24
24
|
/** The numbers behind the bar (omit-when-unset on the wire). */
|
|
@@ -230,13 +230,27 @@ export function useMediaJobs(
|
|
|
230
230
|
setJobs(parseJobs(okValue(r), includeTerminal));
|
|
231
231
|
}, [includeTerminal]);
|
|
232
232
|
|
|
233
|
+
// Seed ONCE, then refresh on PUSH: a worker-reported transition arrives on the
|
|
234
|
+
// shared job stream and we re-read the catalog projection once (coalesced), so
|
|
235
|
+
// placement / stage-log / phase stay the server's list shape — never a timer.
|
|
233
236
|
useEffect(() => {
|
|
234
237
|
mounted.current = true;
|
|
235
238
|
void load();
|
|
236
|
-
|
|
239
|
+
let coalesce: number | null = null;
|
|
240
|
+
const unsub = subscribeJobStream(() => {
|
|
241
|
+
if (coalesce != null) return; // already a reload pending this burst
|
|
242
|
+
coalesce = window.setTimeout(() => {
|
|
243
|
+
coalesce = null;
|
|
244
|
+
void load();
|
|
245
|
+
}, 300);
|
|
246
|
+
});
|
|
247
|
+
const onFocus = () => { void load(); };
|
|
248
|
+
window.addEventListener("focus", onFocus);
|
|
237
249
|
return () => {
|
|
238
250
|
mounted.current = false;
|
|
239
|
-
window.
|
|
251
|
+
if (coalesce != null) window.clearTimeout(coalesce);
|
|
252
|
+
unsub();
|
|
253
|
+
window.removeEventListener("focus", onFocus);
|
|
240
254
|
};
|
|
241
255
|
}, [load]);
|
|
242
256
|
|
package/src/video/useModels.ts
CHANGED
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
// Staleness fix: the registry is no longer fetched only once on mount. Models
|
|
8
8
|
// adopted MID-SESSION (e.g. ae ComfyUI checkpoints that appear once ComfyUI comes
|
|
9
9
|
// up) must enter the picker WITHOUT a full page reload, so the hook now
|
|
10
|
-
// stale-while-revalidates — it re-fetches on window focus / tab visibility
|
|
11
|
-
// gentle background interval (skipped while the tab is hidden), and on demand via
|
|
10
|
+
// stale-while-revalidates — it re-fetches on window focus / tab visibility and on demand via
|
|
12
11
|
// the exposed `refresh()`. Background revalidations never blank a working picker:
|
|
13
12
|
// `loading` flips only for the very first load, and a failed poll keeps the last
|
|
14
13
|
// good list. The public surface gained `refresh` (additive — existing consumers
|
|
@@ -17,6 +16,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
17
16
|
import { z } from "zod";
|
|
18
17
|
import { request, okValue, errorOf, describeAppError } from "../transport/client";
|
|
19
18
|
import { hugpyConfig } from "../config";
|
|
19
|
+
import { sharedRequest, subscribeModelChanges } from "./modelsFeed";
|
|
20
20
|
|
|
21
21
|
export interface ModelOption {
|
|
22
22
|
/** The model id sent to the backend. */
|
|
@@ -119,10 +119,6 @@ function nonTargetReason(m: z.infer<typeof modelEntrySchema>): string {
|
|
|
119
119
|
return "Unclassified — no task derived from this model's files yet.";
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
// Gentle background revalidation cadence (ms). Cheap same-origin GET; only ONE
|
|
123
|
-
// picker instance is mounted at a time (studio sub-tabs share one WorkbenchStation),
|
|
124
|
-
// and polls are skipped while the tab is hidden.
|
|
125
|
-
const REFRESH_INTERVAL_MS = 45_000;
|
|
126
122
|
|
|
127
123
|
// Capability = the FULL `tasks` list; the single `task` (primary) is only the
|
|
128
124
|
// fallback for older payloads. Exported so task-specific pickers (e.g. the Movie
|
|
@@ -167,7 +163,7 @@ export function useImageModels(): ImageModelsState {
|
|
|
167
163
|
});
|
|
168
164
|
|
|
169
165
|
// `mounted` gates setState after unmount; `inFlight` collapses overlapping
|
|
170
|
-
// revalidations (
|
|
166
|
+
// revalidations (focus + manual) into one; `ctrlRef` lets every
|
|
171
167
|
// trigger — including a manual refresh — abort on unmount.
|
|
172
168
|
const mounted = useRef(true);
|
|
173
169
|
const inFlight = useRef(false);
|
|
@@ -176,17 +172,10 @@ export function useImageModels(): ImageModelsState {
|
|
|
176
172
|
const load = useCallback(async (background: boolean) => {
|
|
177
173
|
if (inFlight.current) return;
|
|
178
174
|
inFlight.current = true;
|
|
179
|
-
const signal = ctrlRef.current?.signal;
|
|
180
175
|
try {
|
|
181
176
|
const [mRes, tRes] = await Promise.all([
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
meta: { specKey: "frames", operation: "models.list" },
|
|
185
|
-
}),
|
|
186
|
-
request<unknown>(hugpyConfig.promptTasksUrl, {
|
|
187
|
-
signal,
|
|
188
|
-
meta: { specKey: "frames", operation: "prompt.tasks" },
|
|
189
|
-
}),
|
|
177
|
+
sharedRequest(hugpyConfig.modelsUrl, { specKey: "frames", operation: "models.list" }),
|
|
178
|
+
sharedRequest(hugpyConfig.promptTasksUrl, { specKey: "frames", operation: "prompt.tasks" }),
|
|
190
179
|
]);
|
|
191
180
|
if (!mounted.current) return;
|
|
192
181
|
|
|
@@ -284,28 +273,13 @@ export function useImageModels(): ImageModelsState {
|
|
|
284
273
|
// Foreground load (the only one that toggles `loading` / surfaces an error).
|
|
285
274
|
void load(false);
|
|
286
275
|
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
const revalidate = () => {
|
|
290
|
-
if (document.visibilityState === "hidden") return;
|
|
291
|
-
void load(true);
|
|
292
|
-
};
|
|
293
|
-
window.addEventListener("focus", revalidate);
|
|
294
|
-
document.addEventListener("visibilitychange", revalidate);
|
|
295
|
-
|
|
296
|
-
// Gentle interval backstop for the same-tab case (ComfyUI comes up while the
|
|
297
|
-
// operator is looking at the station). Skipped while the tab is hidden.
|
|
298
|
-
const timer = setInterval(() => {
|
|
299
|
-
if (document.visibilityState === "hidden") return;
|
|
300
|
-
void load(true);
|
|
301
|
-
}, REFRESH_INTERVAL_MS);
|
|
276
|
+
// Re-read only when the DB reports a model-row change (modelsFeed).
|
|
277
|
+
const off = subscribeModelChanges(() => void load(true));
|
|
302
278
|
|
|
303
279
|
return () => {
|
|
304
280
|
mounted.current = false;
|
|
305
281
|
ctrl.abort();
|
|
306
|
-
|
|
307
|
-
document.removeEventListener("visibilitychange", revalidate);
|
|
308
|
-
clearInterval(timer);
|
|
282
|
+
off();
|
|
309
283
|
};
|
|
310
284
|
}, [load]);
|
|
311
285
|
|
|
@@ -328,7 +302,7 @@ export interface TaskModelsState {
|
|
|
328
302
|
* task — the generic sibling of useImageModels for non-generation pickers. The
|
|
329
303
|
* Movie director's judge-model select uses it with "image-text-to-text" to surface
|
|
330
304
|
* the vision-language models that can SCORE a segment. Same tolerant fetch +
|
|
331
|
-
* stale-while-revalidate posture (focus / visibility /
|
|
305
|
+
* stale-while-revalidate posture (focus / visibility / manual refresh);
|
|
332
306
|
* a failed background revalidation keeps the last good list. No task-defaults fetch
|
|
333
307
|
* (there is no canonical default for an arbitrary task) — the caller supplies its
|
|
334
308
|
* own default and keeps it selectable even when the list is empty.
|
|
@@ -348,12 +322,8 @@ export function useModelsByTask(task: string): TaskModelsState {
|
|
|
348
322
|
async (background: boolean) => {
|
|
349
323
|
if (inFlight.current) return;
|
|
350
324
|
inFlight.current = true;
|
|
351
|
-
const signal = ctrlRef.current?.signal;
|
|
352
325
|
try {
|
|
353
|
-
const mRes = await
|
|
354
|
-
signal,
|
|
355
|
-
meta: { specKey: "generate", operation: "models.list" },
|
|
356
|
-
});
|
|
326
|
+
const mRes = await sharedRequest(hugpyConfig.modelsUrl, { specKey: "generate", operation: "models.list" });
|
|
357
327
|
if (!mounted.current) return;
|
|
358
328
|
if (!mRes.ok) {
|
|
359
329
|
if (background) return;
|
|
@@ -386,22 +356,11 @@ export function useModelsByTask(task: string): TaskModelsState {
|
|
|
386
356
|
const ctrl = new AbortController();
|
|
387
357
|
ctrlRef.current = ctrl;
|
|
388
358
|
void load(false);
|
|
389
|
-
const
|
|
390
|
-
if (document.visibilityState === "hidden") return;
|
|
391
|
-
void load(true);
|
|
392
|
-
};
|
|
393
|
-
window.addEventListener("focus", revalidate);
|
|
394
|
-
document.addEventListener("visibilitychange", revalidate);
|
|
395
|
-
const timer = setInterval(() => {
|
|
396
|
-
if (document.visibilityState === "hidden") return;
|
|
397
|
-
void load(true);
|
|
398
|
-
}, REFRESH_INTERVAL_MS);
|
|
359
|
+
const off = subscribeModelChanges(() => void load(true));
|
|
399
360
|
return () => {
|
|
400
361
|
mounted.current = false;
|
|
401
362
|
ctrl.abort();
|
|
402
|
-
|
|
403
|
-
document.removeEventListener("visibilitychange", revalidate);
|
|
404
|
-
clearInterval(timer);
|
|
363
|
+
off();
|
|
405
364
|
};
|
|
406
365
|
}, [load]);
|
|
407
366
|
|
|
@@ -23,10 +23,7 @@ import {
|
|
|
23
23
|
studioMovieResumeUrl,
|
|
24
24
|
} from "../config";
|
|
25
25
|
import { request, okValue, errorOf, describeAppError } from "../transport/client";
|
|
26
|
-
|
|
27
|
-
// 6s — the same background cadence useStudioClips polls the clip catalog at. A movie
|
|
28
|
-
// segment takes minutes, so anything faster only costs a filesystem walk.
|
|
29
|
-
const POLL_MS = 6000;
|
|
26
|
+
import { subscribeJobStream } from "./jobStream";
|
|
30
27
|
|
|
31
28
|
// The bus statuses that mean "this session's job is still going to do something" —
|
|
32
29
|
// the client-side mirror of the route's _MOVIE_INFLIGHT_STATES, used to decide
|
|
@@ -223,11 +220,23 @@ export function useMovieSessions(active: boolean): MovieSessionsState {
|
|
|
223
220
|
}
|
|
224
221
|
}, []);
|
|
225
222
|
|
|
223
|
+
// Seed once when the panel opens, then re-read the movies root only when a
|
|
224
|
+
// worker reports a job transition (coalesced) — no 6s filesystem-walk poll.
|
|
226
225
|
useEffect(() => {
|
|
227
226
|
if (!active) return;
|
|
228
227
|
void load();
|
|
229
|
-
|
|
230
|
-
|
|
228
|
+
let coalesce: number | null = null;
|
|
229
|
+
const unsub = subscribeJobStream(() => {
|
|
230
|
+
if (coalesce != null) return;
|
|
231
|
+
coalesce = window.setTimeout(() => {
|
|
232
|
+
coalesce = null;
|
|
233
|
+
void load();
|
|
234
|
+
}, 400);
|
|
235
|
+
});
|
|
236
|
+
return () => {
|
|
237
|
+
if (coalesce != null) window.clearTimeout(coalesce);
|
|
238
|
+
unsub();
|
|
239
|
+
};
|
|
231
240
|
}, [active, load]);
|
|
232
241
|
|
|
233
242
|
// Pause and resume differ only in their url and their telemetry op, so they share
|
|
@@ -1,105 +1,15 @@
|
|
|
1
|
-
// OPERATOR HISTORY HYDRATION
|
|
2
|
-
// COMPLETE visibility of previous renders in the Session Library — not just
|
|
3
|
-
// what this browser tab produced. The library itself stays a per-session
|
|
4
|
-
// sessionStorage working set (by design); this hook back-fills it from the
|
|
5
|
-
// SERVER record once per session for an operator principal:
|
|
1
|
+
// OPERATOR HISTORY HYDRATION — RETIRED (2026-10-05).
|
|
6
2
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// the operator removed stays removed), genKind-tagged so the Generate
|
|
16
|
-
// station can filter the strips by its CURRENT SECTION.
|
|
17
|
-
//
|
|
18
|
-
// Once-per-session guard in sessionStorage; canned demo never hydrates.
|
|
19
|
-
import { useEffect } from "react";
|
|
20
|
-
import { request, okValue } from "../transport/client";
|
|
21
|
-
import { hugpyConfig, jobStatusUrl } from "../config";
|
|
22
|
-
import { addToLibrary, type LibraryGenKind } from "./mediaLibrary";
|
|
23
|
-
import { getSessionId } from "../session";
|
|
24
|
-
import { isCanned } from "../demo/mode";
|
|
25
|
-
import type { MediaRef } from "./contract";
|
|
26
|
-
|
|
27
|
-
// generate_studio_movie = CINEMA renders — hydrated too (mapped onto the
|
|
28
|
-
// generate_movie library kind: same Videos shelf, no schema change).
|
|
29
|
-
const GEN_KINDS: readonly string[] = [
|
|
30
|
-
"generate_image", "generate_scene", "generate_movie", "generate_studio_movie",
|
|
31
|
-
];
|
|
32
|
-
// v2 (2026-08-13): the once-per-session flag must change whenever the
|
|
33
|
-
// HYDRATED KINDS change — a tab that hydrated v1 (no cinema jobs) kept its
|
|
34
|
-
// flag through every reload and never saw generate_studio_movie history.
|
|
35
|
-
const FLAG_PREFIX = "vi.operatorHistory.v2";
|
|
36
|
-
const DETAIL_CAP = 150; // newest-first; enough history without hammering the API
|
|
37
|
-
|
|
38
|
-
function flagKey(): string {
|
|
39
|
-
try { return `${FLAG_PREFIX}:${getSessionId()}`; } catch { return FLAG_PREFIX; }
|
|
40
|
-
}
|
|
3
|
+
// This hook back-filled the Session Library from the server record once per
|
|
4
|
+
// session, but ONLY for a console operator, and it decided operator-ness by
|
|
5
|
+
// probing GET /keys/video-share — the same 200⇒operator / 401⇒guest probe
|
|
6
|
+
// SharePanel used. That route has no backend in this build: the probe only ever
|
|
7
|
+
// 404'd, so the hydration never ran. With no operator signal to replace it (and
|
|
8
|
+
// per the directive to drop the dead /keys/video-share calls), this is now a
|
|
9
|
+
// no-op rather than a dead request fired on every page load. The exported
|
|
10
|
+
// signature is kept so its one caller stays valid.
|
|
41
11
|
|
|
12
|
+
/** No-op: operator history hydration is retired (see the file header). */
|
|
42
13
|
export function useOperatorHistory(): void {
|
|
43
|
-
|
|
44
|
-
if (isCanned()) return;
|
|
45
|
-
try { if (sessionStorage.getItem(flagKey())) return; } catch { /* no-op */ }
|
|
46
|
-
let cancelled = false;
|
|
47
|
-
void (async () => {
|
|
48
|
-
// Operator probe — non-operators (401/403) never hydrate.
|
|
49
|
-
const probe = await request<unknown>(hugpyConfig.keysVideoShareUrl, {
|
|
50
|
-
meta: { specKey: "media", operation: "operator.probe" },
|
|
51
|
-
});
|
|
52
|
-
if (cancelled || !probe.ok) return;
|
|
53
|
-
// jobStatusUrl("") = `${apiBase}/video/jobs/` — trim the slash and query
|
|
54
|
-
// the list route with terminal rows included.
|
|
55
|
-
const list = await request<unknown>(
|
|
56
|
-
`${jobStatusUrl("").replace(/\/$/, "")}?all=1&limit=300`,
|
|
57
|
-
{ meta: { specKey: "media", operation: "history.jobs.list" } },
|
|
58
|
-
);
|
|
59
|
-
if (cancelled || !list.ok) return;
|
|
60
|
-
const jobs = ((okValue(list) as { jobs?: unknown[] })?.jobs ?? []) as Array<
|
|
61
|
-
Record<string, unknown>
|
|
62
|
-
>;
|
|
63
|
-
const wanted = jobs
|
|
64
|
-
.filter((j) => j?.status === "done" && GEN_KINDS.includes(String(j?.name)))
|
|
65
|
-
.slice(0, DETAIL_CAP);
|
|
66
|
-
for (const j of wanted) {
|
|
67
|
-
if (cancelled) return;
|
|
68
|
-
const id = String(j.job_id ?? j.id ?? "");
|
|
69
|
-
if (!id) continue;
|
|
70
|
-
const det = await request<unknown>(jobStatusUrl(id), {
|
|
71
|
-
meta: { specKey: "media", operation: "history.job.detail" },
|
|
72
|
-
});
|
|
73
|
-
if (!det.ok) continue;
|
|
74
|
-
const result = (okValue(det) as { result?: { outputs?: unknown[] } })?.result;
|
|
75
|
-
for (const o of (result?.outputs ?? []) as Array<Record<string, unknown>>) {
|
|
76
|
-
if (!o?.uri || !o?.asset_id) continue;
|
|
77
|
-
const ref = {
|
|
78
|
-
asset_id: String(o.asset_id),
|
|
79
|
-
kind: (o.kind === "video" ? "video" : "image"),
|
|
80
|
-
uri: String(o.uri),
|
|
81
|
-
mime: (o.mime as string) ?? (o.kind === "video" ? "video/mp4" : "image/png"),
|
|
82
|
-
width: (o.width as number) ?? null,
|
|
83
|
-
height: (o.height as number) ?? null,
|
|
84
|
-
duration_s: (o.duration_s as number) ?? null,
|
|
85
|
-
fps_native: (o.fps_native as number) ?? null,
|
|
86
|
-
channels: (o.channels as number) ?? null,
|
|
87
|
-
sample_rate: (o.sample_rate as number) ?? null,
|
|
88
|
-
} as MediaRef;
|
|
89
|
-
addToLibrary(ref, "history", `${String(j.name)} · ${id.slice(0, 8)}`, {
|
|
90
|
-
genKind: (j.name === "generate_studio_movie"
|
|
91
|
-
? "generate_movie"
|
|
92
|
-
: j.name) as LibraryGenKind,
|
|
93
|
-
// groupId = the producing job: each historical run groups as its own
|
|
94
|
-
// work (like live pushes) instead of one giant shared "history" pile
|
|
95
|
-
// whose identical key collided ACROSS the type sections (expanding
|
|
96
|
-
// Videos/history also expanded Images/history).
|
|
97
|
-
groupId: id,
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
try { sessionStorage.setItem(flagKey(), String(Date.now())); } catch { /* no-op */ }
|
|
102
|
-
})();
|
|
103
|
-
return () => { cancelled = true; };
|
|
104
|
-
}, []);
|
|
14
|
+
/* intentionally empty — no /keys/video-share probe, no history back-fill */
|
|
105
15
|
}
|
|
@@ -37,7 +37,7 @@ export type AssistMode = "detail" | "generate";
|
|
|
37
37
|
export type AssistBusyMode = AssistMode | "spread" | "negative";
|
|
38
38
|
|
|
39
39
|
/** The active sub-mode, sent as `context.kind` so video modes get motion/camera phrasing. */
|
|
40
|
-
export type AssistKind = "image" | "scene" | "movie";
|
|
40
|
+
export type AssistKind = "image" | "scene" | "movie" | "clip";
|
|
41
41
|
|
|
42
42
|
/** The `specKey` a host's telemetry rides under (matches the host's other calls). */
|
|
43
43
|
type SpecKey = "generate" | "studio";
|
|
@@ -53,11 +53,13 @@ export interface UsePromptAssistOptions {
|
|
|
53
53
|
export interface AssistModel {
|
|
54
54
|
/** Catalog key sent back as `body.model`. */
|
|
55
55
|
model: string;
|
|
56
|
-
/** Seated on a worker right now
|
|
56
|
+
/** Seated on a worker right now. */
|
|
57
57
|
serving: boolean;
|
|
58
|
+
/** Present on a worker, but not necessarily loaded. */
|
|
59
|
+
held?: boolean;
|
|
58
60
|
/** Canonical residency (STATE-MODEL.md #4): serving|loaded = ready; hot = on a
|
|
59
61
|
* worker drive (pays a t_load); cold = central only (pays a download + load). */
|
|
60
|
-
state?: "serving" | "loaded" | "hot" | "cold";
|
|
62
|
+
state?: "serving" | "loaded" | "hot" | "cold" | "unknown";
|
|
61
63
|
framework?: string | null;
|
|
62
64
|
/** True for the fleet default — used when the user has never chosen. */
|
|
63
65
|
default?: boolean;
|
|
@@ -183,6 +185,7 @@ async function assistCall(
|
|
|
183
185
|
const value = okValue(res) as { ticket?: string; pending?: boolean } | null;
|
|
184
186
|
const ticket = value?.ticket;
|
|
185
187
|
if (!ticket) return res; // direct (non-ticket) result — serve as-is
|
|
188
|
+
if (value?.pending === false) return res; // current orbit returns the completed result with its ticket
|
|
186
189
|
const deadline = Date.now() + TICKET_CEILING_MS;
|
|
187
190
|
for (;;) {
|
|
188
191
|
await new Promise((r) => setTimeout(r, TICKET_POLL_MS));
|
package/src/video/useToks.ts
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
16
16
|
import { hugpyConfig } from "../config";
|
|
17
17
|
import { request, okValue } from "../transport/client";
|
|
18
|
+
import { subscribeJobStream } from "./jobStream";
|
|
18
19
|
|
|
19
|
-
const POLL_MS = 2000;
|
|
20
20
|
const RECENT_LIMIT = 100;
|
|
21
21
|
|
|
22
22
|
/** One recent inference sample (omit-when-unset tolerated on the wire). */
|
|
@@ -127,22 +127,30 @@ export function useToks(): {
|
|
|
127
127
|
setLive(recent.ok || report.ok);
|
|
128
128
|
}, []);
|
|
129
129
|
|
|
130
|
+
// Seed once, then refresh on PUSH: the shared job stream fires on every
|
|
131
|
+
// worker-reported transition (coalesced), so the tok/s tail tracks live work
|
|
132
|
+
// without a 2s timer. No job activity → the last-good snapshot simply stands.
|
|
130
133
|
useEffect(() => {
|
|
131
134
|
mounted.current = true;
|
|
132
135
|
void load();
|
|
133
|
-
|
|
136
|
+
let coalesce: number | null = null;
|
|
137
|
+
const unsub = subscribeJobStream(() => {
|
|
138
|
+
if (coalesce != null) return;
|
|
139
|
+
coalesce = window.setTimeout(() => {
|
|
140
|
+
coalesce = null;
|
|
141
|
+
void load();
|
|
142
|
+
}, 400);
|
|
143
|
+
});
|
|
134
144
|
return () => {
|
|
135
145
|
mounted.current = false;
|
|
136
|
-
window.
|
|
146
|
+
if (coalesce != null) window.clearTimeout(coalesce);
|
|
147
|
+
unsub();
|
|
137
148
|
};
|
|
138
149
|
}, [load]);
|
|
139
150
|
|
|
140
151
|
return { entries, groups, live };
|
|
141
152
|
}
|
|
142
153
|
|
|
143
|
-
/** The poll cadence, exported so the panel header can label it ("every 2s"). */
|
|
144
|
-
export const TOKS_POLL_MS = POLL_MS;
|
|
145
|
-
|
|
146
154
|
/** "12s ago" / "3m ago" from an epoch-seconds ts, "" when unknown. */
|
|
147
155
|
export function tokAgo(ts: number | null, nowMs: number): string {
|
|
148
156
|
if (ts == null) return "";
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** One status seed followed by pushed job transitions. No periodic GET loop. */
|
|
2
|
+
import { videoJobUrl } from "../config";
|
|
3
|
+
import { request, okValue } from "../transport/client";
|
|
4
|
+
import { subscribeJobStream } from "./jobStream";
|
|
5
|
+
|
|
6
|
+
export function watchVideoJob(
|
|
7
|
+
jobId: string,
|
|
8
|
+
onJob: (job: Record<string, unknown>) => boolean | void,
|
|
9
|
+
onTimeout: () => void,
|
|
10
|
+
timeoutMs: number,
|
|
11
|
+
): () => void {
|
|
12
|
+
let active = true;
|
|
13
|
+
let pushed = false;
|
|
14
|
+
let unsubscribe: (() => void) | null = null;
|
|
15
|
+
const deadline = Date.now() + timeoutMs;
|
|
16
|
+
const timer = window.setTimeout(() => {
|
|
17
|
+
if (!active || Date.now() < deadline) return;
|
|
18
|
+
stop();
|
|
19
|
+
onTimeout();
|
|
20
|
+
}, timeoutMs);
|
|
21
|
+
const stop = () => {
|
|
22
|
+
if (!active) return;
|
|
23
|
+
active = false;
|
|
24
|
+
window.clearTimeout(timer);
|
|
25
|
+
unsubscribe?.();
|
|
26
|
+
unsubscribe = null;
|
|
27
|
+
};
|
|
28
|
+
const deliver = (row: unknown) => {
|
|
29
|
+
if (!active || !row || typeof row !== "object") return;
|
|
30
|
+
if (onJob(row as Record<string, unknown>) === true) stop();
|
|
31
|
+
};
|
|
32
|
+
unsubscribe = subscribeJobStream((ev) => {
|
|
33
|
+
if (ev.job_id !== jobId || !ev.job) return;
|
|
34
|
+
pushed = true;
|
|
35
|
+
deliver(ev.job);
|
|
36
|
+
});
|
|
37
|
+
void request<unknown>(videoJobUrl(jobId), {
|
|
38
|
+
meta: { specKey: "studio", operation: "video.job.seed" },
|
|
39
|
+
}).then((res) => {
|
|
40
|
+
if (res.ok && !pushed) deliver(okValue(res));
|
|
41
|
+
});
|
|
42
|
+
return stop;
|
|
43
|
+
}
|