@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,279 @@
|
|
|
1
|
+
// A live view of every IN-FLIGHT media-bus job (studio / movie / identity /
|
|
2
|
+
// generate / crop / extract) WITH PLACEMENT — the console-wide "Active Processes"
|
|
3
|
+
// feed. Polls GET /video/jobs (the media CATALOG projection), which unlike the
|
|
4
|
+
// unified /llm/jobs feed carries a `placement` object: WHERE each render
|
|
5
|
+
// physically executes (ae · cuda:0 · P-studio), and surfaces the reservation
|
|
6
|
+
// `awaiting_capacity` HOLD phase + its shortfall reason + overtaken count.
|
|
7
|
+
//
|
|
8
|
+
// Honest rendering only: `progress` is projected ONLY when the server sends it —
|
|
9
|
+
// no fabricated bars. Cadence ~2s; a fetch error keeps the last-good list rather
|
|
10
|
+
// than flashing empty. Mounted-ref guarded so a late poll never setState after
|
|
11
|
+
// unmount, and the interval is cleared on unmount.
|
|
12
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
13
|
+
import { hugpyConfig } from "../config";
|
|
14
|
+
import { request, okValue } from "../transport/client";
|
|
15
|
+
// The stage-timeline + failure shapes are the SAME wire objects the studio tab
|
|
16
|
+
// already renders through `ProcessTimeline`; reusing its types (and, in the panel,
|
|
17
|
+
// the component itself) keeps one renderer for "what it did / where it broke"
|
|
18
|
+
// instead of a second, divergent one for the console-wide view.
|
|
19
|
+
import type { StageEntry, JobFailure } from "../stations/studio/studioShared";
|
|
20
|
+
|
|
21
|
+
const POLL_MS = 2000;
|
|
22
|
+
const IN_FLIGHT = new Set(["queued", "claimed", "running", "cancelling"]);
|
|
23
|
+
|
|
24
|
+
/** The numbers behind the bar (omit-when-unset on the wire). */
|
|
25
|
+
export interface ProgressDetail {
|
|
26
|
+
segmentDone: number | null;
|
|
27
|
+
segmentTotal: number | null;
|
|
28
|
+
step: number | null;
|
|
29
|
+
steps: number | null;
|
|
30
|
+
fraction: number | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** WHERE a job physically executes (omit-when-unset on the wire). */
|
|
34
|
+
export interface Placement {
|
|
35
|
+
source: string | null; // "reservation" | "template"
|
|
36
|
+
host: string | null; // box: "ae" | "central" | ...
|
|
37
|
+
workerId: string | null;
|
|
38
|
+
gpu: string | null; // device: "cuda:0" | null (cpu)
|
|
39
|
+
process: string | null; // "P-studio" | "P-comfy" | "P-identity" | "ffmpeg" | ...
|
|
40
|
+
reservedBytes: number | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The reservation HOLD marker (progress.phase === "awaiting_capacity"). */
|
|
44
|
+
export interface HoldInfo {
|
|
45
|
+
reason: Record<string, unknown> | null;
|
|
46
|
+
heldSince: number | null;
|
|
47
|
+
overtaken: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One in-flight media-bus job, projected for the Active Processes panel. */
|
|
51
|
+
export interface MediaJob {
|
|
52
|
+
id: string;
|
|
53
|
+
name: string; // bus job kind
|
|
54
|
+
status: string; // queued | claimed | running | cancelling
|
|
55
|
+
createdAt: number | null; // epoch seconds
|
|
56
|
+
principal: string | null;
|
|
57
|
+
/** Effective phase chip: the reservation hold overrides the raw status. */
|
|
58
|
+
phase: string; // queued | awaiting_capacity | running | claimed | cancelling
|
|
59
|
+
hold: HoldInfo | null; // set only when phase === "awaiting_capacity"
|
|
60
|
+
progress: number | null; // 0..1 when the server sends one, else null
|
|
61
|
+
progressDetail: ProgressDetail | null; // the numbers behind the bar
|
|
62
|
+
stage: string | null;
|
|
63
|
+
placement: Placement | null;
|
|
64
|
+
/** The server's stage timeline: a TAIL while live, the whole thing when terminal. */
|
|
65
|
+
stageLog: StageEntry[];
|
|
66
|
+
stageLogTotal: number; // how many entries exist server-side (tail elides the rest)
|
|
67
|
+
/** The failure envelope of a terminal-failed job — rendered verbatim, never "failed". */
|
|
68
|
+
failure: JobFailure | null;
|
|
69
|
+
terminal: boolean; // done | failed | cancelled (only present with the history filter)
|
|
70
|
+
progressedAt: number | null; // last REAL movement (the stall/stale basis)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function str(v: unknown): string | null {
|
|
74
|
+
return typeof v === "string" && v.trim() ? v : null;
|
|
75
|
+
}
|
|
76
|
+
function num(v: unknown): number | null {
|
|
77
|
+
return typeof v === "number" && isFinite(v) ? v : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parsePlacement(raw: unknown): Placement | null {
|
|
81
|
+
if (!raw || typeof raw !== "object") return null;
|
|
82
|
+
const p = raw as Record<string, unknown>;
|
|
83
|
+
return {
|
|
84
|
+
source: str(p.source),
|
|
85
|
+
host: str(p.host),
|
|
86
|
+
workerId: str(p.worker_id),
|
|
87
|
+
gpu: str(p.gpu),
|
|
88
|
+
process: str(p.process),
|
|
89
|
+
reservedBytes: num(p.reserved_bytes),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The timeline entries, kept in their WIRE shape for `ProcessTimeline`. */
|
|
94
|
+
function parseStageLog(raw: unknown): StageEntry[] {
|
|
95
|
+
if (!Array.isArray(raw)) return [];
|
|
96
|
+
return raw.filter(
|
|
97
|
+
(e): e is StageEntry =>
|
|
98
|
+
!!e && typeof e === "object" && typeof (e as StageEntry).stage === "string",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The failure envelope, passed through UNTOUCHED. Whatever code/message the
|
|
104
|
+
* backend produced (e.g. `deps_missing` with its pip remedy) is what the panel
|
|
105
|
+
* renders — no summarising, no generic "failed".
|
|
106
|
+
*/
|
|
107
|
+
function parseFailure(raw: unknown): JobFailure | null {
|
|
108
|
+
if (!raw || typeof raw !== "object") return null;
|
|
109
|
+
const f = raw as JobFailure;
|
|
110
|
+
return f.code || f.message ? f : null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseProgressDetail(raw: unknown): ProgressDetail | null {
|
|
114
|
+
if (!raw || typeof raw !== "object") return null;
|
|
115
|
+
const d = raw as Record<string, unknown>;
|
|
116
|
+
return {
|
|
117
|
+
segmentDone: num(d.segment_done),
|
|
118
|
+
segmentTotal: num(d.segment_total),
|
|
119
|
+
step: num(d.step),
|
|
120
|
+
steps: num(d.steps),
|
|
121
|
+
fraction: num(d.fraction),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseJobs(raw: unknown, includeTerminal: boolean): MediaJob[] {
|
|
126
|
+
const jobs = (raw as { jobs?: unknown } | null)?.jobs;
|
|
127
|
+
if (!Array.isArray(jobs)) return [];
|
|
128
|
+
const out: MediaJob[] = [];
|
|
129
|
+
for (const j of jobs) {
|
|
130
|
+
if (!j || typeof j !== "object") continue;
|
|
131
|
+
const row = j as Record<string, unknown>;
|
|
132
|
+
const id = str(row.job_id);
|
|
133
|
+
if (!id) continue;
|
|
134
|
+
const status = str(row.status) ?? "";
|
|
135
|
+
const terminal = !IN_FLIGHT.has(status);
|
|
136
|
+
if (terminal && !includeTerminal) continue;
|
|
137
|
+
const progress = (row.progress as Record<string, unknown> | null) ?? null;
|
|
138
|
+
const phaseRaw =
|
|
139
|
+
progress && typeof progress === "object" ? str(progress.phase) : null;
|
|
140
|
+
const isHold = phaseRaw === "awaiting_capacity";
|
|
141
|
+
let hold: HoldInfo | null = null;
|
|
142
|
+
if (isHold && progress) {
|
|
143
|
+
hold = {
|
|
144
|
+
reason:
|
|
145
|
+
progress.reason && typeof progress.reason === "object"
|
|
146
|
+
? (progress.reason as Record<string, unknown>)
|
|
147
|
+
: null,
|
|
148
|
+
heldSince: num(progress.held_since),
|
|
149
|
+
overtaken: num(progress.overtaken) ?? 0,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
out.push({
|
|
153
|
+
id,
|
|
154
|
+
name: str(row.name) ?? "",
|
|
155
|
+
status,
|
|
156
|
+
createdAt: num(row.created),
|
|
157
|
+
principal: str(row.principal),
|
|
158
|
+
phase: isHold ? "awaiting_capacity" : status,
|
|
159
|
+
hold,
|
|
160
|
+
// Honest: only surface a numeric progress the server actually sent. The
|
|
161
|
+
// server now computes it (segments done + the within-segment denoise
|
|
162
|
+
// fraction) and sends null when the runner reports nothing measurable, so a
|
|
163
|
+
// missing bar still means "unknown", never "zero".
|
|
164
|
+
progress: num(row.progress_ratio),
|
|
165
|
+
progressDetail: parseProgressDetail(row.progress_detail),
|
|
166
|
+
stage: str(row.current_stage) ?? (progress ? str(progress.stage) : null),
|
|
167
|
+
placement: parsePlacement(row.placement),
|
|
168
|
+
stageLog: parseStageLog(row.stage_log),
|
|
169
|
+
stageLogTotal: num(row.stage_log_total) ?? 0,
|
|
170
|
+
failure: parseFailure(row.failure),
|
|
171
|
+
terminal,
|
|
172
|
+
progressedAt: num(row.progressed_at) ?? num(row.last_movement_ts),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The live in-flight media jobs + a manual `refetch` (fired right after a cancel
|
|
180
|
+
* so the row disappears without waiting for the next poll tick). Polls only while
|
|
181
|
+
* a consumer is mounted.
|
|
182
|
+
*
|
|
183
|
+
* `includeTerminal` asks the server for recent terminal rows too (?all=1) — the
|
|
184
|
+
* panel's "history" filter, which is how a FAILED render (and its verbatim failure
|
|
185
|
+
* envelope) stays inspectable after it leaves the in-flight set. The default feed
|
|
186
|
+
* is live work only: the server hides in-flight rows abandoned by a dead process,
|
|
187
|
+
* so days-old ghosts no longer crowd the panel.
|
|
188
|
+
*/
|
|
189
|
+
export function useMediaJobs(
|
|
190
|
+
includeTerminal = false,
|
|
191
|
+
): { jobs: MediaJob[]; refetch: () => void } {
|
|
192
|
+
const [jobs, setJobs] = useState<MediaJob[]>([]);
|
|
193
|
+
const mounted = useRef(true);
|
|
194
|
+
|
|
195
|
+
const load = useCallback(async () => {
|
|
196
|
+
const url = includeTerminal
|
|
197
|
+
? `${hugpyConfig.mediaJobsUrl}?all=1`
|
|
198
|
+
: hugpyConfig.mediaJobsUrl;
|
|
199
|
+
const r = await request<unknown>(url, {
|
|
200
|
+
meta: { specKey: "media", operation: "media.jobs.poll" },
|
|
201
|
+
});
|
|
202
|
+
if (!mounted.current) return;
|
|
203
|
+
if (!r.ok) return; // transient — keep last-good, retry next tick
|
|
204
|
+
setJobs(parseJobs(okValue(r), includeTerminal));
|
|
205
|
+
}, [includeTerminal]);
|
|
206
|
+
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
mounted.current = true;
|
|
209
|
+
void load();
|
|
210
|
+
const id = window.setInterval(() => void load(), POLL_MS);
|
|
211
|
+
return () => {
|
|
212
|
+
mounted.current = false;
|
|
213
|
+
window.clearInterval(id);
|
|
214
|
+
};
|
|
215
|
+
}, [load]);
|
|
216
|
+
|
|
217
|
+
return { jobs, refetch: load };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── shared render helpers (used by the console-wide panel + the studio tab) ──
|
|
221
|
+
|
|
222
|
+
/** "ae · cuda:0 · P-studio" — the placement one-liner, or "" when unknown. */
|
|
223
|
+
export function placementLine(p: Placement | null): string {
|
|
224
|
+
if (!p) return "";
|
|
225
|
+
const parts = [p.host, p.gpu ?? "cpu", p.process].filter(
|
|
226
|
+
(x): x is string => typeof x === "string" && x.length > 0,
|
|
227
|
+
);
|
|
228
|
+
return parts.join(" · ");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** True when the job runs OFF the central box (an external worker/service). */
|
|
232
|
+
export function isExternal(p: Placement | null): boolean {
|
|
233
|
+
return !!p && !!p.host && p.host !== "central";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Human GiB for a reserved-bytes value, or "" when absent. */
|
|
237
|
+
export function reservedGib(p: Placement | null): string {
|
|
238
|
+
if (!p || p.reservedBytes == null) return "";
|
|
239
|
+
return `${(p.reservedBytes / 1024 ** 3).toFixed(1)} GB reserved`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* "42% · segment 1/2 · step 12/30" — the label under the bar, built only from the
|
|
244
|
+
* numbers the server actually sent. "" when there is nothing honest to say.
|
|
245
|
+
*/
|
|
246
|
+
export function progressLabel(job: MediaJob): string {
|
|
247
|
+
const parts: string[] = [];
|
|
248
|
+
if (job.progress != null) parts.push(`${Math.round(job.progress * 100)}%`);
|
|
249
|
+
const d = job.progressDetail;
|
|
250
|
+
if (d?.segmentTotal != null && d.segmentDone != null) {
|
|
251
|
+
// 1-based for humans: while segment 0 renders, an operator reads "segment 1/2".
|
|
252
|
+
const at = Math.min(d.segmentDone + 1, d.segmentTotal);
|
|
253
|
+
parts.push(`segment ${at}/${d.segmentTotal}`);
|
|
254
|
+
}
|
|
255
|
+
if (d?.step != null && d.steps != null) parts.push(`step ${d.step}/${d.steps}`);
|
|
256
|
+
return parts.join(" · ");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The freshest timeline line — "segment 0/2 · t2v · worker rendering". This is the
|
|
261
|
+
* one-line answer to "what is it doing", shown WITHOUT expanding the row, because
|
|
262
|
+
* "no logs are ever shown" was the operator's complaint and a detail that needs a
|
|
263
|
+
* click is still a detail nobody sees.
|
|
264
|
+
*/
|
|
265
|
+
export function latestEvent(job: MediaJob): string {
|
|
266
|
+
for (let i = job.stageLog.length - 1; i >= 0; i -= 1) {
|
|
267
|
+
const d = job.stageLog[i]?.detail;
|
|
268
|
+
if (typeof d === "string" && d.trim()) return d;
|
|
269
|
+
}
|
|
270
|
+
return "";
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Elapsed since an epoch-seconds start, as "12s" / "3m 4s". */
|
|
274
|
+
export function elapsedSince(createdAt: number | null): string {
|
|
275
|
+
if (createdAt == null) return "";
|
|
276
|
+
const secs = Math.max(0, Math.floor(Date.now() / 1000 - createdAt));
|
|
277
|
+
if (secs < 60) return `${secs}s`;
|
|
278
|
+
return `${Math.floor(secs / 60)}m ${secs % 60}s`;
|
|
279
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// The shared image-model dropdown source. Fetches the model registry and the task
|
|
2
|
+
// defaults, filters to the GENERATION image tasks (text-to-image + image-to-image)
|
|
3
|
+
// off the FULL `tasks` capability list, and exposes the options + the default id +
|
|
4
|
+
// loading/error as data (never throws). Both the Frames station (config surface)
|
|
5
|
+
// and the later Generate station import this verbatim.
|
|
6
|
+
//
|
|
7
|
+
// Staleness fix: the registry is no longer fetched only once on mount. Models
|
|
8
|
+
// adopted MID-SESSION (e.g. ae ComfyUI checkpoints that appear once ComfyUI comes
|
|
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, on a
|
|
11
|
+
// gentle background interval (skipped while the tab is hidden), and on demand via
|
|
12
|
+
// the exposed `refresh()`. Background revalidations never blank a working picker:
|
|
13
|
+
// `loading` flips only for the very first load, and a failed poll keeps the last
|
|
14
|
+
// good list. The public surface gained `refresh` (additive — existing consumers
|
|
15
|
+
// that destructure the four data fields are unaffected).
|
|
16
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { request, okValue, errorOf, describeAppError } from "../transport/client";
|
|
19
|
+
import { hugpyConfig } from "../config";
|
|
20
|
+
|
|
21
|
+
export interface ModelOption {
|
|
22
|
+
/** The model id sent to the backend. */
|
|
23
|
+
id: string;
|
|
24
|
+
/** Human label for the dropdown (hub_id when present, else the id). */
|
|
25
|
+
label: string;
|
|
26
|
+
/**
|
|
27
|
+
* The model's FULL generation-capability list (from `capabilitiesOf`) — carried
|
|
28
|
+
* through so the Generate station can drive REQUIRED inputs from it (see
|
|
29
|
+
* `requiredInputsFor`). Previously discarded after the label was built.
|
|
30
|
+
*/
|
|
31
|
+
tasks: string[];
|
|
32
|
+
/**
|
|
33
|
+
* Present but NOT pickable — a LoRA adapter or a pipeline component (an
|
|
34
|
+
* encoder/VAE split). Rendered greyed with `reason` rather than offered
|
|
35
|
+
* (it would refuse) or hidden (the operator then can't see the file they
|
|
36
|
+
* downloaded). See the k61 incident note on GENERATION_IMAGE_TASKS.
|
|
37
|
+
*/
|
|
38
|
+
disabled?: boolean;
|
|
39
|
+
/** Why it can't be picked — shown as the option's title. */
|
|
40
|
+
reason?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ImageModelsState {
|
|
44
|
+
models: ModelOption[];
|
|
45
|
+
defaultId: string | null;
|
|
46
|
+
loading: boolean;
|
|
47
|
+
error: string | null;
|
|
48
|
+
/**
|
|
49
|
+
* Force an immediate re-fetch of the registry (manual refresh affordance).
|
|
50
|
+
* Runs as a background revalidation — it does not clear the current list.
|
|
51
|
+
*/
|
|
52
|
+
refresh: () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Tolerant schemas — the registry carries many fields we ignore; passthrough keeps
|
|
56
|
+
// them and we only assert the three we read.
|
|
57
|
+
const modelEntrySchema = z
|
|
58
|
+
.object({
|
|
59
|
+
id: z.string(),
|
|
60
|
+
task: z.string().nullable().optional(),
|
|
61
|
+
// Full capability list (0.1.122+): `task` is only the PRIMARY — filtering
|
|
62
|
+
// on it alone hid secondary capabilities (dual t2i+img2img models).
|
|
63
|
+
tasks: z.array(z.string()).nullable().optional(),
|
|
64
|
+
hub_id: z.string().nullable().optional(),
|
|
65
|
+
// Why a present model still can't serve (0.1.x+, k61): adapters and
|
|
66
|
+
// pipeline components are real files that are not servable on their own.
|
|
67
|
+
adapter: z.boolean().nullable().optional(),
|
|
68
|
+
serveable: z.boolean().nullable().optional(),
|
|
69
|
+
unserveable_reason: z.string().nullable().optional(),
|
|
70
|
+
})
|
|
71
|
+
.passthrough();
|
|
72
|
+
|
|
73
|
+
const modelsResponseSchema = z
|
|
74
|
+
.object({ data: z.array(modelEntrySchema) })
|
|
75
|
+
.passthrough();
|
|
76
|
+
|
|
77
|
+
const promptTasksSchema = z
|
|
78
|
+
.object({
|
|
79
|
+
defaults: z.record(z.string()).nullable().optional(),
|
|
80
|
+
})
|
|
81
|
+
.passthrough();
|
|
82
|
+
|
|
83
|
+
const IMAGE_TASK = "text-to-image";
|
|
84
|
+
// Since the img2img go-live (map §12.5 follow-up), image-to-image models
|
|
85
|
+
// (Qwen-Image-Edit, flux-klein) are first-class generate targets — but they
|
|
86
|
+
// REQUIRE a start image (no text-only path), so their labels say so.
|
|
87
|
+
const IMG2IMG_TASK = "image-to-image";
|
|
88
|
+
// The full set of GENERATION / EDITING image tasks this workspace can drive: t2i
|
|
89
|
+
// produces from text, img2img edits a start image. Both stations consume exactly
|
|
90
|
+
// these. Analysis-only image tasks (classification, captioning, detection, …) are
|
|
91
|
+
// NOT generate targets, so they are deliberately excluded — a model must advertise
|
|
92
|
+
// one of these in its capability list to enter the picker.
|
|
93
|
+
const GENERATION_IMAGE_TASKS: readonly string[] = [IMAGE_TASK, IMG2IMG_TASK];
|
|
94
|
+
|
|
95
|
+
// Rows that are NOT generation targets but must still be explicable in an image
|
|
96
|
+
// picker (k61, 2026-07-31). The flux2 incident put a text-encoder COMPONENT and
|
|
97
|
+
// an image LoRA in front of the operator as if they were models: the encoder was
|
|
98
|
+
// selectable in image UIs where it can never serve, and the LoRA — left
|
|
99
|
+
// unclassified and defaulted to text-generation — refused with
|
|
100
|
+
// "supported: ['text-generation']". They now enter the list GREYED, carrying the
|
|
101
|
+
// backend's own reason, so the affordance says what the file is instead of
|
|
102
|
+
// pretending it can generate.
|
|
103
|
+
const ADAPTER_TASK = "adapter";
|
|
104
|
+
const COMPONENT_TASK = "pipeline-component";
|
|
105
|
+
const NEEDS_CLASSIFICATION_TASK = "needs-classification";
|
|
106
|
+
const EXPLAINED_NON_TARGETS: readonly string[] = [
|
|
107
|
+
ADAPTER_TASK,
|
|
108
|
+
COMPONENT_TASK,
|
|
109
|
+
NEEDS_CLASSIFICATION_TASK,
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
function nonTargetReason(m: z.infer<typeof modelEntrySchema>): string {
|
|
113
|
+
if (m.unserveable_reason) return m.unserveable_reason;
|
|
114
|
+
const caps = capabilitiesOf(m);
|
|
115
|
+
if (m.adapter || caps.includes(ADAPTER_TASK))
|
|
116
|
+
return "LoRA adapter — apply it to a base image model; it can't generate on its own.";
|
|
117
|
+
if (caps.includes(COMPONENT_TASK))
|
|
118
|
+
return "Pipeline component (encoder/VAE) — served through its parent pipeline, not on its own.";
|
|
119
|
+
return "Unclassified — no task derived from this model's files yet.";
|
|
120
|
+
}
|
|
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
|
+
|
|
127
|
+
// Capability = the FULL `tasks` list; the single `task` (primary) is only the
|
|
128
|
+
// fallback for older payloads. Exported so task-specific pickers (e.g. the Movie
|
|
129
|
+
// director's judge-VLM select) can filter the registry the same way.
|
|
130
|
+
export function capabilitiesOf(m: z.infer<typeof modelEntrySchema>): string[] {
|
|
131
|
+
return m.tasks?.length ? m.tasks : m.task ? [m.task] : [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The REQUIRED-input contract for a model, derived purely from its capability
|
|
136
|
+
* `tasks[]`. The Generate station drives its explicit prompt/start-image controls
|
|
137
|
+
* off this instead of leaving the user to hand-add parts. Truth table:
|
|
138
|
+
*
|
|
139
|
+
* tasks includes… → image
|
|
140
|
+
* ─────────────────────────────────────────
|
|
141
|
+
* text-to-image only → "none" (t2i can't take a start image)
|
|
142
|
+
* text-to-image + img2img → "optional" (start image ⇒ img2img edit)
|
|
143
|
+
* image-to-image only → "required" (edit model — no text-only path)
|
|
144
|
+
* empty / unrecognized → "optional" (safe default: text + optional image)
|
|
145
|
+
*
|
|
146
|
+
* `text` is always "required" — every generation path here needs a prompt.
|
|
147
|
+
*/
|
|
148
|
+
export function requiredInputsFor(
|
|
149
|
+
tasks: string[],
|
|
150
|
+
): { text: "required"; image: "required" | "optional" | "none" } {
|
|
151
|
+
const t2i = tasks.includes(IMAGE_TASK);
|
|
152
|
+
const i2i = tasks.includes(IMG2IMG_TASK);
|
|
153
|
+
let image: "required" | "optional" | "none";
|
|
154
|
+
if (i2i && !t2i) image = "required"; // edit-only model
|
|
155
|
+
else if (t2i && i2i) image = "optional"; // dual t2i + img2img
|
|
156
|
+
else if (t2i && !i2i) image = "none"; // pure text-to-image
|
|
157
|
+
else image = "optional"; // empty / unknown capabilities → safe default
|
|
158
|
+
return { text: "required", image };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function useImageModels(): ImageModelsState {
|
|
162
|
+
const [state, setState] = useState<Omit<ImageModelsState, "refresh">>({
|
|
163
|
+
models: [],
|
|
164
|
+
defaultId: null,
|
|
165
|
+
loading: true,
|
|
166
|
+
error: null,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// `mounted` gates setState after unmount; `inFlight` collapses overlapping
|
|
170
|
+
// revalidations (interval + focus + manual) into one; `ctrlRef` lets every
|
|
171
|
+
// trigger — including a manual refresh — abort on unmount.
|
|
172
|
+
const mounted = useRef(true);
|
|
173
|
+
const inFlight = useRef(false);
|
|
174
|
+
const ctrlRef = useRef<AbortController | null>(null);
|
|
175
|
+
|
|
176
|
+
const load = useCallback(async (background: boolean) => {
|
|
177
|
+
if (inFlight.current) return;
|
|
178
|
+
inFlight.current = true;
|
|
179
|
+
const signal = ctrlRef.current?.signal;
|
|
180
|
+
try {
|
|
181
|
+
const [mRes, tRes] = await Promise.all([
|
|
182
|
+
request<unknown>(hugpyConfig.modelsUrl, {
|
|
183
|
+
signal,
|
|
184
|
+
meta: { specKey: "frames", operation: "models.list" },
|
|
185
|
+
}),
|
|
186
|
+
request<unknown>(hugpyConfig.promptTasksUrl, {
|
|
187
|
+
signal,
|
|
188
|
+
meta: { specKey: "frames", operation: "prompt.tasks" },
|
|
189
|
+
}),
|
|
190
|
+
]);
|
|
191
|
+
if (!mounted.current) return;
|
|
192
|
+
|
|
193
|
+
if (!mRes.ok) {
|
|
194
|
+
// A background revalidation that fails must NOT wipe a working picker —
|
|
195
|
+
// keep the last good list; only the first (foreground) load surfaces it.
|
|
196
|
+
if (background) return;
|
|
197
|
+
setState({
|
|
198
|
+
models: [],
|
|
199
|
+
defaultId: null,
|
|
200
|
+
loading: false,
|
|
201
|
+
error: describeAppError(errorOf(mRes)),
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const mParsed = modelsResponseSchema.safeParse(okValue(mRes));
|
|
206
|
+
if (!mParsed.success) {
|
|
207
|
+
if (background) return;
|
|
208
|
+
setState({
|
|
209
|
+
models: [],
|
|
210
|
+
defaultId: null,
|
|
211
|
+
loading: false,
|
|
212
|
+
error: "Malformed models response.",
|
|
213
|
+
});
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Only a model that ADVERTISES a generation image task is pickable. Adapters,
|
|
218
|
+
// pipeline components and unclassified rows are appended after them, greyed,
|
|
219
|
+
// so they are explained rather than offered or hidden.
|
|
220
|
+
const rows = mParsed.data.data.filter((m) => {
|
|
221
|
+
const caps = capabilitiesOf(m);
|
|
222
|
+
return (
|
|
223
|
+
caps.some((t) => GENERATION_IMAGE_TASKS.includes(t)) ||
|
|
224
|
+
m.adapter === true ||
|
|
225
|
+
caps.some((t) => EXPLAINED_NON_TARGETS.includes(t))
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
const models: ModelOption[] = rows
|
|
229
|
+
.map((m) => {
|
|
230
|
+
const caps = capabilitiesOf(m);
|
|
231
|
+
const t2i = caps.includes(IMAGE_TASK);
|
|
232
|
+
const i2i = caps.includes(IMG2IMG_TASK);
|
|
233
|
+
if (!t2i && !i2i) {
|
|
234
|
+
const reason = nonTargetReason(m);
|
|
235
|
+
return {
|
|
236
|
+
id: m.id,
|
|
237
|
+
label: `${m.hub_id ?? m.id} · not selectable`,
|
|
238
|
+
tasks: caps,
|
|
239
|
+
disabled: true,
|
|
240
|
+
reason,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const suffix = i2i && !t2i
|
|
244
|
+
? " · img2img (needs a start image)"
|
|
245
|
+
: i2i && t2i
|
|
246
|
+
? " · t2i + img2img"
|
|
247
|
+
: "";
|
|
248
|
+
// Carry the full capability list through — the Generate station reads it
|
|
249
|
+
// via `requiredInputsFor(...)` to drive its required prompt/start-image UI.
|
|
250
|
+
return { id: m.id, label: (m.hub_id ?? m.id) + suffix, tasks: caps };
|
|
251
|
+
})
|
|
252
|
+
.sort((a, b) => Number(a.disabled ?? false) - Number(b.disabled ?? false));
|
|
253
|
+
|
|
254
|
+
// Task defaults are best-effort — a failure there still yields a usable list.
|
|
255
|
+
let defaultId: string | null = null;
|
|
256
|
+
if (tRes.ok) {
|
|
257
|
+
const tParsed = promptTasksSchema.safeParse(okValue(tRes));
|
|
258
|
+
if (tParsed.success) {
|
|
259
|
+
const d = tParsed.data.defaults?.[IMAGE_TASK];
|
|
260
|
+
if (typeof d === "string") defaultId = d;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Guarantee a SELECTABLE default: fall back to the first pickable option
|
|
264
|
+
// when the task default is absent, greyed, or not in the list at all.
|
|
265
|
+
const pickable = models.filter((m) => !m.disabled);
|
|
266
|
+
if (
|
|
267
|
+
(!defaultId || !pickable.some((m) => m.id === defaultId)) &&
|
|
268
|
+
pickable.length > 0
|
|
269
|
+
) {
|
|
270
|
+
defaultId = pickable[0].id;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
setState({ models, defaultId, loading: false, error: null });
|
|
274
|
+
} finally {
|
|
275
|
+
inFlight.current = false;
|
|
276
|
+
}
|
|
277
|
+
}, []);
|
|
278
|
+
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
mounted.current = true;
|
|
281
|
+
const ctrl = new AbortController();
|
|
282
|
+
ctrlRef.current = ctrl;
|
|
283
|
+
|
|
284
|
+
// Foreground load (the only one that toggles `loading` / surfaces an error).
|
|
285
|
+
void load(false);
|
|
286
|
+
|
|
287
|
+
// Revalidate when the tab/window regains focus or becomes visible — a
|
|
288
|
+
// returning operator sees models adopted while they were away, no reload.
|
|
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);
|
|
302
|
+
|
|
303
|
+
return () => {
|
|
304
|
+
mounted.current = false;
|
|
305
|
+
ctrl.abort();
|
|
306
|
+
window.removeEventListener("focus", revalidate);
|
|
307
|
+
document.removeEventListener("visibilitychange", revalidate);
|
|
308
|
+
clearInterval(timer);
|
|
309
|
+
};
|
|
310
|
+
}, [load]);
|
|
311
|
+
|
|
312
|
+
const refresh = useCallback(() => {
|
|
313
|
+
void load(true);
|
|
314
|
+
}, [load]);
|
|
315
|
+
|
|
316
|
+
return { ...state, refresh };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface TaskModelsState {
|
|
320
|
+
models: ModelOption[];
|
|
321
|
+
loading: boolean;
|
|
322
|
+
error: string | null;
|
|
323
|
+
refresh: () => void;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* A registry list filtered to models whose capability `tasks[]` include ONE given
|
|
328
|
+
* task — the generic sibling of useImageModels for non-generation pickers. The
|
|
329
|
+
* Movie director's judge-model select uses it with "image-text-to-text" to surface
|
|
330
|
+
* the vision-language models that can SCORE a segment. Same tolerant fetch +
|
|
331
|
+
* stale-while-revalidate posture (focus / visibility / interval / manual refresh);
|
|
332
|
+
* a failed background revalidation keeps the last good list. No task-defaults fetch
|
|
333
|
+
* (there is no canonical default for an arbitrary task) — the caller supplies its
|
|
334
|
+
* own default and keeps it selectable even when the list is empty.
|
|
335
|
+
*/
|
|
336
|
+
export function useModelsByTask(task: string): TaskModelsState {
|
|
337
|
+
const [state, setState] = useState<Omit<TaskModelsState, "refresh">>({
|
|
338
|
+
models: [],
|
|
339
|
+
loading: true,
|
|
340
|
+
error: null,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const mounted = useRef(true);
|
|
344
|
+
const inFlight = useRef(false);
|
|
345
|
+
const ctrlRef = useRef<AbortController | null>(null);
|
|
346
|
+
|
|
347
|
+
const load = useCallback(
|
|
348
|
+
async (background: boolean) => {
|
|
349
|
+
if (inFlight.current) return;
|
|
350
|
+
inFlight.current = true;
|
|
351
|
+
const signal = ctrlRef.current?.signal;
|
|
352
|
+
try {
|
|
353
|
+
const mRes = await request<unknown>(hugpyConfig.modelsUrl, {
|
|
354
|
+
signal,
|
|
355
|
+
meta: { specKey: "generate", operation: "models.list" },
|
|
356
|
+
});
|
|
357
|
+
if (!mounted.current) return;
|
|
358
|
+
if (!mRes.ok) {
|
|
359
|
+
if (background) return;
|
|
360
|
+
setState({ models: [], loading: false, error: describeAppError(errorOf(mRes)) });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const mParsed = modelsResponseSchema.safeParse(okValue(mRes));
|
|
364
|
+
if (!mParsed.success) {
|
|
365
|
+
if (background) return;
|
|
366
|
+
setState({ models: [], loading: false, error: "Malformed models response." });
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const models: ModelOption[] = mParsed.data.data
|
|
370
|
+
.filter((m) => capabilitiesOf(m).includes(task))
|
|
371
|
+
.map((m) => ({
|
|
372
|
+
id: m.id,
|
|
373
|
+
label: m.hub_id ?? m.id,
|
|
374
|
+
tasks: capabilitiesOf(m),
|
|
375
|
+
}));
|
|
376
|
+
setState({ models, loading: false, error: null });
|
|
377
|
+
} finally {
|
|
378
|
+
inFlight.current = false;
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
[task],
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
mounted.current = true;
|
|
386
|
+
const ctrl = new AbortController();
|
|
387
|
+
ctrlRef.current = ctrl;
|
|
388
|
+
void load(false);
|
|
389
|
+
const revalidate = () => {
|
|
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);
|
|
399
|
+
return () => {
|
|
400
|
+
mounted.current = false;
|
|
401
|
+
ctrl.abort();
|
|
402
|
+
window.removeEventListener("focus", revalidate);
|
|
403
|
+
document.removeEventListener("visibilitychange", revalidate);
|
|
404
|
+
clearInterval(timer);
|
|
405
|
+
};
|
|
406
|
+
}, [load]);
|
|
407
|
+
|
|
408
|
+
const refresh = useCallback(() => {
|
|
409
|
+
void load(true);
|
|
410
|
+
}, [load]);
|
|
411
|
+
|
|
412
|
+
return { ...state, refresh };
|
|
413
|
+
}
|