@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,184 @@
|
|
|
1
|
+
// The image TESTER's enqueue surface: N prompt rows -> N generate jobs, each
|
|
2
|
+
// with its OWN settings, tracked independently so they can be compared side by
|
|
3
|
+
// side.
|
|
4
|
+
//
|
|
5
|
+
// This is the multi-job sibling of useGenerateJob (ONE prompt -> ONE job). It
|
|
6
|
+
// rides the same session-wide jobTracker, so a tester run keeps going (and lands
|
|
7
|
+
// in the library) after the station unmounts or the page reloads — but it books
|
|
8
|
+
// its jobs under a DISTINCT station key.
|
|
9
|
+
//
|
|
10
|
+
// That station key matters. useGenerateJob resolves "my job" as the NEWEST
|
|
11
|
+
// record with station === "generate", so if tester rows were filed under the
|
|
12
|
+
// same key, every row would hijack the base composer's result tile and the last
|
|
13
|
+
// row to enqueue would masquerade as the single-prompt run. Filing tester jobs
|
|
14
|
+
// under "generate-tester" keeps the two surfaces from ever reading each other's
|
|
15
|
+
// records.
|
|
16
|
+
import { useCallback, useMemo, useState } from "react";
|
|
17
|
+
import { request, okValue, errorOf, describeAppError } from "../transport/client";
|
|
18
|
+
import { hugpyConfig } from "../config";
|
|
19
|
+
import {
|
|
20
|
+
enqueueResultSchema,
|
|
21
|
+
promptSummary,
|
|
22
|
+
isMovieProgress,
|
|
23
|
+
type GenerateImageRequest,
|
|
24
|
+
type JobStatus,
|
|
25
|
+
type MediaRef,
|
|
26
|
+
type JobProgress,
|
|
27
|
+
} from "../video/contract";
|
|
28
|
+
import {
|
|
29
|
+
useJobTracker,
|
|
30
|
+
trackJob,
|
|
31
|
+
dismissTerminal,
|
|
32
|
+
isJobActive,
|
|
33
|
+
} from "../video/jobTracker";
|
|
34
|
+
|
|
35
|
+
/** Live state of ONE tester row's job. */
|
|
36
|
+
export interface RowJobState {
|
|
37
|
+
jobId: string | null;
|
|
38
|
+
status: JobStatus;
|
|
39
|
+
output: MediaRef | null;
|
|
40
|
+
error: string | null;
|
|
41
|
+
running: boolean;
|
|
42
|
+
progress: JobProgress | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface GenerateRowJobsApi {
|
|
46
|
+
/** Row id -> that row's job state. Rows never run are absent. */
|
|
47
|
+
byRow: Record<string, RowJobState>;
|
|
48
|
+
/** True while ANY row is enqueuing or polling. */
|
|
49
|
+
anyRunning: boolean;
|
|
50
|
+
/** Enqueue one job per row. Replaces the previous tester run. */
|
|
51
|
+
runRows: (rows: { rowId: string; req: GenerateImageRequest }[]) => void;
|
|
52
|
+
/** Forget this tester's finished jobs. */
|
|
53
|
+
reset: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const STATION = "generate-tester";
|
|
57
|
+
|
|
58
|
+
const IDLE: RowJobState = {
|
|
59
|
+
jobId: null,
|
|
60
|
+
status: null,
|
|
61
|
+
output: null,
|
|
62
|
+
error: null,
|
|
63
|
+
running: false,
|
|
64
|
+
progress: null,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export function useGenerateRowJobs(): GenerateRowJobsApi {
|
|
68
|
+
const records = useJobTracker();
|
|
69
|
+
// rowId -> jobId for the CURRENT run. Rebuilt on every runRows so a re-run
|
|
70
|
+
// never shows a stale row's previous image beside a fresh one.
|
|
71
|
+
const [jobByRow, setJobByRow] = useState<Record<string, string>>({});
|
|
72
|
+
// rowId -> enqueue-time failure (never reached a job_id, so the tracker has
|
|
73
|
+
// nothing to report for it).
|
|
74
|
+
const [enqueueErrors, setEnqueueErrors] = useState<Record<string, string>>({});
|
|
75
|
+
const [enqueuing, setEnqueuing] = useState<Record<string, boolean>>({});
|
|
76
|
+
|
|
77
|
+
const byRow = useMemo<Record<string, RowJobState>>(() => {
|
|
78
|
+
const mine = new Map(
|
|
79
|
+
records
|
|
80
|
+
.filter((r) => r.station === STATION && r.kind === "generate_image")
|
|
81
|
+
.map((r) => [r.jobId, r] as const),
|
|
82
|
+
);
|
|
83
|
+
const out: Record<string, RowJobState> = {};
|
|
84
|
+
const rowIds = new Set([
|
|
85
|
+
...Object.keys(jobByRow),
|
|
86
|
+
...Object.keys(enqueueErrors),
|
|
87
|
+
...Object.keys(enqueuing),
|
|
88
|
+
]);
|
|
89
|
+
for (const rowId of rowIds) {
|
|
90
|
+
const enqErr = enqueueErrors[rowId];
|
|
91
|
+
if (enqErr) {
|
|
92
|
+
out[rowId] = { ...IDLE, status: "failed", error: enqErr };
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const jobId = jobByRow[rowId];
|
|
96
|
+
const rec = jobId ? mine.get(jobId) : undefined;
|
|
97
|
+
if (!rec) {
|
|
98
|
+
// Enqueued but no job_id yet (or the tracker dropped it): running only
|
|
99
|
+
// while the POST is genuinely in flight, so a lost record reads idle
|
|
100
|
+
// rather than spinning forever.
|
|
101
|
+
out[rowId] = { ...IDLE, running: !!enqueuing[rowId] };
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
out[rowId] = {
|
|
105
|
+
jobId: rec.jobId,
|
|
106
|
+
status: rec.status,
|
|
107
|
+
output: rec.outputs[0] ?? null,
|
|
108
|
+
error:
|
|
109
|
+
rec.error ??
|
|
110
|
+
(rec.expired ? "This job is no longer tracked by the server." : null),
|
|
111
|
+
running: isJobActive(rec),
|
|
112
|
+
// generate_image only ever carries flat progress — narrow off the
|
|
113
|
+
// tracker's union so a movie blob can never land here.
|
|
114
|
+
progress:
|
|
115
|
+
rec.progress && !isMovieProgress(rec.progress) ? rec.progress : null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}, [records, jobByRow, enqueueErrors, enqueuing]);
|
|
120
|
+
|
|
121
|
+
const anyRunning = useMemo(
|
|
122
|
+
() => Object.keys(byRow).some((rowId) => byRow[rowId].running),
|
|
123
|
+
[byRow],
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const reset = useCallback(() => {
|
|
127
|
+
setJobByRow({});
|
|
128
|
+
setEnqueueErrors({});
|
|
129
|
+
setEnqueuing({});
|
|
130
|
+
dismissTerminal(STATION);
|
|
131
|
+
}, []);
|
|
132
|
+
|
|
133
|
+
const runRows = useCallback(
|
|
134
|
+
(rows: { rowId: string; req: GenerateImageRequest }[]) => {
|
|
135
|
+
if (rows.length === 0) return;
|
|
136
|
+
// Clear the previous run up front so no row shows a stale result while the
|
|
137
|
+
// new one enqueues.
|
|
138
|
+
setJobByRow({});
|
|
139
|
+
setEnqueueErrors({});
|
|
140
|
+
setEnqueuing(Object.fromEntries(rows.map((r) => [r.rowId, true])));
|
|
141
|
+
|
|
142
|
+
for (const { rowId, req } of rows) {
|
|
143
|
+
const enqueuedAt = Date.now();
|
|
144
|
+
void request<unknown>(hugpyConfig.generateEnqueueUrl, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
body: JSON.stringify(req),
|
|
147
|
+
headers: { "Content-Type": "application/json" },
|
|
148
|
+
meta: { specKey: STATION, operation: "generate_image.enqueue" },
|
|
149
|
+
}).then((r) => {
|
|
150
|
+
setEnqueuing((prev) => ({ ...prev, [rowId]: false }));
|
|
151
|
+
if (!r.ok) {
|
|
152
|
+
setEnqueueErrors((prev) => ({
|
|
153
|
+
...prev,
|
|
154
|
+
[rowId]: describeAppError(errorOf(r)),
|
|
155
|
+
}));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const parsed = enqueueResultSchema.safeParse(okValue(r));
|
|
159
|
+
if (!parsed.success) {
|
|
160
|
+
setEnqueueErrors((prev) => ({
|
|
161
|
+
...prev,
|
|
162
|
+
[rowId]: "Malformed enqueue response.",
|
|
163
|
+
}));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const jobId = parsed.data.job_id;
|
|
167
|
+
trackJob({
|
|
168
|
+
jobId,
|
|
169
|
+
kind: "generate_image",
|
|
170
|
+
station: STATION,
|
|
171
|
+
label: "tester row",
|
|
172
|
+
enqueuedAt,
|
|
173
|
+
prompt: promptSummary(req.parts),
|
|
174
|
+
model: req.model_id,
|
|
175
|
+
});
|
|
176
|
+
setJobByRow((prev) => ({ ...prev, [rowId]: jobId }));
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
[],
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
return { byRow, anyRunning, runRows, reset };
|
|
184
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// The Generate station's SCENE enqueue surface — Scene mode's sibling to
|
|
2
|
+
// useGenerateJob. ONE ordered multimodal prompt → ONE generate_scene job → N
|
|
3
|
+
// consecutive image frames + (optionally) one assembled mp4 clip. Cloned from
|
|
4
|
+
// useGenerateJob: this hook owns request-body construction and POSTs it through
|
|
5
|
+
// request<T>() + config URLs; the moment it has a job_id it hands the job to
|
|
6
|
+
// jobTracker.trackJob and stops owning any polling or job state. Polling, the
|
|
7
|
+
// n_frames-scaled scene poll cap (generateScenePollCapMs, plumbed via
|
|
8
|
+
// trackJob.nFrames → capFor), the terminal parse, and the centralized library
|
|
9
|
+
// push (frames + the clip, origin "generate") all live in jobTracker — so a scene
|
|
10
|
+
// kicked off here keeps running (and lands in the library) even after the user
|
|
11
|
+
// switches sub-tabs (which unmounts this station) or reloads the page.
|
|
12
|
+
//
|
|
13
|
+
// The ONLY shape difference from useGenerateJob is the output: Scene mode reads
|
|
14
|
+
// the FULL result.outputs array (N image refs, then the clip last), so this hook
|
|
15
|
+
// exposes `outputs: MediaRef[]` instead of a single `output`.
|
|
16
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
17
|
+
import { request, okValue, errorOf, describeAppError } from "../transport/client";
|
|
18
|
+
import { hugpyConfig } from "../config";
|
|
19
|
+
import {
|
|
20
|
+
enqueueResultSchema,
|
|
21
|
+
promptSummary,
|
|
22
|
+
isMovieProgress,
|
|
23
|
+
type GenerateSceneRequest,
|
|
24
|
+
type JobStatus,
|
|
25
|
+
type MediaRef,
|
|
26
|
+
type JobProgress,
|
|
27
|
+
type JobProject,
|
|
28
|
+
} from "../video/contract";
|
|
29
|
+
import {
|
|
30
|
+
useJobTracker,
|
|
31
|
+
trackJob,
|
|
32
|
+
resumeJob,
|
|
33
|
+
dismissTerminal,
|
|
34
|
+
isJobActive,
|
|
35
|
+
} from "../video/jobTracker";
|
|
36
|
+
|
|
37
|
+
export interface GenerateSceneJobApi {
|
|
38
|
+
/** Server job id once enqueued, else null. */
|
|
39
|
+
jobId: string | null;
|
|
40
|
+
/** Latest polled status (null = idle or enqueued-but-not-yet-claimed). */
|
|
41
|
+
status: JobStatus;
|
|
42
|
+
/** ALL produced MediaRefs (N image frames, then the clip last if assembled). */
|
|
43
|
+
outputs: MediaRef[];
|
|
44
|
+
/** Human message on failure (enqueue error or job error). */
|
|
45
|
+
error: string | null;
|
|
46
|
+
/** Poll cap reached while non-terminal — NOT a failure. */
|
|
47
|
+
capped: boolean;
|
|
48
|
+
/** True while enqueuing or polling a non-terminal job. */
|
|
49
|
+
running: boolean;
|
|
50
|
+
/** Live per-run progress while running (done/total, stage, step, frames-so-far); null otherwise. */
|
|
51
|
+
progress: JobProgress | null;
|
|
52
|
+
/** Auto-archive project this scene was saved into (populated on done); null otherwise. */
|
|
53
|
+
project: JobProject | null;
|
|
54
|
+
/** Enqueue ONE generate_scene job and begin polling. Replaces any prior run. */
|
|
55
|
+
run: (req: GenerateSceneRequest) => void;
|
|
56
|
+
/** Re-anchor the poll clock and resume polling a capped job. Clears `capped`. */
|
|
57
|
+
resume: () => void;
|
|
58
|
+
/** Forget this station's finished job (called when the composer is cleared). */
|
|
59
|
+
reset: () => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const STATION = "generate";
|
|
63
|
+
|
|
64
|
+
export function useGenerateSceneJob(): GenerateSceneJobApi {
|
|
65
|
+
const records = useJobTracker();
|
|
66
|
+
const [currentJobId, setCurrentJobId] = useState<string | null>(null);
|
|
67
|
+
const [enqueuing, setEnqueuing] = useState<boolean>(false);
|
|
68
|
+
const [enqueueError, setEnqueueError] = useState<string | null>(null);
|
|
69
|
+
|
|
70
|
+
const record = useMemo(() => {
|
|
71
|
+
const mine = records.filter(
|
|
72
|
+
(r) => r.station === STATION && r.kind === "generate_scene",
|
|
73
|
+
);
|
|
74
|
+
if (currentJobId) return mine.find((r) => r.jobId === currentJobId) ?? null;
|
|
75
|
+
if (enqueuing) return null;
|
|
76
|
+
return mine.slice().sort((a, b) => b.enqueuedAt - a.enqueuedAt)[0] ?? null;
|
|
77
|
+
}, [records, currentJobId, enqueuing]);
|
|
78
|
+
|
|
79
|
+
const jobIdRef = useRef<string | null>(record?.jobId ?? null);
|
|
80
|
+
jobIdRef.current = record?.jobId ?? null;
|
|
81
|
+
|
|
82
|
+
const reset = useCallback(() => {
|
|
83
|
+
setCurrentJobId(null);
|
|
84
|
+
setEnqueuing(false);
|
|
85
|
+
setEnqueueError(null);
|
|
86
|
+
dismissTerminal(STATION);
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
const resume = useCallback(() => {
|
|
90
|
+
const id = jobIdRef.current;
|
|
91
|
+
if (id) resumeJob(id);
|
|
92
|
+
}, []);
|
|
93
|
+
|
|
94
|
+
const run = useCallback((req: GenerateSceneRequest) => {
|
|
95
|
+
setCurrentJobId(null);
|
|
96
|
+
setEnqueueError(null);
|
|
97
|
+
setEnqueuing(true);
|
|
98
|
+
const enqueuedAt = Date.now();
|
|
99
|
+
|
|
100
|
+
void request<unknown>(hugpyConfig.generateSceneEnqueueUrl, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
body: JSON.stringify(req),
|
|
103
|
+
headers: { "Content-Type": "application/json" },
|
|
104
|
+
meta: { specKey: STATION, operation: "generate_scene.enqueue" },
|
|
105
|
+
}).then((r) => {
|
|
106
|
+
setEnqueuing(false);
|
|
107
|
+
if (!r.ok) {
|
|
108
|
+
setEnqueueError(describeAppError(errorOf(r)));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const parsed = enqueueResultSchema.safeParse(okValue(r));
|
|
112
|
+
if (!parsed.success) {
|
|
113
|
+
setEnqueueError("Malformed enqueue response.");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const jobId = parsed.data.job_id;
|
|
117
|
+
trackJob({
|
|
118
|
+
jobId,
|
|
119
|
+
kind: "generate_scene",
|
|
120
|
+
station: STATION,
|
|
121
|
+
label: "generate scene",
|
|
122
|
+
enqueuedAt,
|
|
123
|
+
nFrames: req.n_frames,
|
|
124
|
+
chain: req.chain,
|
|
125
|
+
prompt: promptSummary(req.parts),
|
|
126
|
+
model: req.model_id,
|
|
127
|
+
});
|
|
128
|
+
setCurrentJobId(jobId);
|
|
129
|
+
});
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
const status: JobStatus = enqueueError ? "failed" : record?.status ?? null;
|
|
133
|
+
const error =
|
|
134
|
+
enqueueError ??
|
|
135
|
+
record?.error ??
|
|
136
|
+
(record?.expired ? "This job is no longer tracked by the server." : null);
|
|
137
|
+
|
|
138
|
+
// A generate_scene record only ever carries flat progress; narrow off the tracker's
|
|
139
|
+
// union (never a nested movie blob for this kind).
|
|
140
|
+
const progress: JobProgress | null =
|
|
141
|
+
record?.progress && !isMovieProgress(record.progress) ? record.progress : null;
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
jobId: record?.jobId ?? null,
|
|
145
|
+
status,
|
|
146
|
+
outputs: record?.outputs ?? [],
|
|
147
|
+
error,
|
|
148
|
+
capped: record?.capped ?? false,
|
|
149
|
+
running: enqueuing || (record != null && isJobActive(record)),
|
|
150
|
+
progress,
|
|
151
|
+
project: record?.project ?? null,
|
|
152
|
+
run,
|
|
153
|
+
resume,
|
|
154
|
+
reset,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// The Scene composer's PER-PART enqueue surface: N independent scene prompts ->
|
|
2
|
+
// N generate_scene jobs, each with its OWN settings, tracked independently so
|
|
3
|
+
// each prompt component produces and renders its own output ("2 prompts = 2
|
|
4
|
+
// outputs"). Rides the session-wide jobTracker (so a fan-out survives an unmount
|
|
5
|
+
// / reload) under a DISTINCT station key.
|
|
6
|
+
//
|
|
7
|
+
// SEQUENTIAL (operator 2026-08-10): the jobs run ONE AT A TIME — a component's
|
|
8
|
+
// generation must fully finish before the next starts. On a single GPU that both
|
|
9
|
+
// matches "test all models" reality (no concurrency benefit for big models) and
|
|
10
|
+
// avoids the VRAM contention / polite-load refusals that a simultaneous fan-out
|
|
11
|
+
// hit (a 17 GB model can't load while N others are mid-load). Not-yet-started
|
|
12
|
+
// rows show as "queued"; each output appears as its model completes.
|
|
13
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
14
|
+
import { request, okValue, errorOf, describeAppError } from "../transport/client";
|
|
15
|
+
import { hugpyConfig } from "../config";
|
|
16
|
+
import {
|
|
17
|
+
enqueueResultSchema,
|
|
18
|
+
promptSummary,
|
|
19
|
+
isMovieProgress,
|
|
20
|
+
type GenerateSceneRequest,
|
|
21
|
+
type JobStatus,
|
|
22
|
+
type MediaRef,
|
|
23
|
+
type JobProgress,
|
|
24
|
+
} from "../video/contract";
|
|
25
|
+
import {
|
|
26
|
+
useJobTracker,
|
|
27
|
+
trackJob,
|
|
28
|
+
resumeJob,
|
|
29
|
+
dismissTerminal,
|
|
30
|
+
isJobActive,
|
|
31
|
+
type TrackedJob,
|
|
32
|
+
} from "../video/jobTracker";
|
|
33
|
+
|
|
34
|
+
/** Live state of ONE scene part's job. */
|
|
35
|
+
export interface SceneRowJobState {
|
|
36
|
+
jobId: string | null;
|
|
37
|
+
status: JobStatus;
|
|
38
|
+
/** ALL produced MediaRefs (N image frames, then the clip last if assembled). */
|
|
39
|
+
outputs: MediaRef[];
|
|
40
|
+
error: string | null;
|
|
41
|
+
running: boolean;
|
|
42
|
+
/** Waiting its turn in the sequential queue (not yet submitted). */
|
|
43
|
+
queued: boolean;
|
|
44
|
+
/** Poll cap reached while non-terminal — NOT a failure; resume() re-anchors it. */
|
|
45
|
+
capped: boolean;
|
|
46
|
+
progress: JobProgress | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface GenerateSceneRowJobsApi {
|
|
50
|
+
/** Part id -> that part's job state. Parts never run are absent. */
|
|
51
|
+
byRow: Record<string, SceneRowJobState>;
|
|
52
|
+
/** True while ANY part is queued, enqueuing, or polling. */
|
|
53
|
+
anyRunning: boolean;
|
|
54
|
+
/** Enqueue one scene job per part — SEQUENTIALLY. Replaces the previous fan-out. */
|
|
55
|
+
runRows: (rows: { rowId: string; req: GenerateSceneRequest }[]) => void;
|
|
56
|
+
/** Re-anchor the poll clock and resume polling a capped part's job. */
|
|
57
|
+
resume: (rowId: string) => void;
|
|
58
|
+
/** Forget this surface's finished jobs and stop any in-flight sequence. */
|
|
59
|
+
reset: () => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const STATION = "generate-scene-parts";
|
|
63
|
+
const WAIT_CAP_MS = 20 * 60 * 1000; // don't let one wedged job stall the whole sweep
|
|
64
|
+
|
|
65
|
+
const IDLE: SceneRowJobState = {
|
|
66
|
+
jobId: null,
|
|
67
|
+
status: null,
|
|
68
|
+
outputs: [],
|
|
69
|
+
error: null,
|
|
70
|
+
running: false,
|
|
71
|
+
queued: false,
|
|
72
|
+
capped: false,
|
|
73
|
+
progress: null,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const sleep = (ms: number) => new Promise<void>((res) => setTimeout(res, ms));
|
|
77
|
+
|
|
78
|
+
export function useGenerateSceneRowJobs(): GenerateSceneRowJobsApi {
|
|
79
|
+
const records = useJobTracker();
|
|
80
|
+
// Latest tracker records, readable inside the async sequential loop (no stale
|
|
81
|
+
// closure) so it can wait for a job to go terminal using the tracker's own poll.
|
|
82
|
+
const recordsRef = useRef(records);
|
|
83
|
+
recordsRef.current = records;
|
|
84
|
+
|
|
85
|
+
const [jobByRow, setJobByRow] = useState<Record<string, string>>({});
|
|
86
|
+
const [enqueueErrors, setEnqueueErrors] = useState<Record<string, string>>({});
|
|
87
|
+
const [enqueuing, setEnqueuing] = useState<Record<string, boolean>>({});
|
|
88
|
+
const [queued, setQueued] = useState<Record<string, boolean>>({});
|
|
89
|
+
// Bumped on each runRows / reset so a superseded sequence stops itself.
|
|
90
|
+
const runSeq = useRef(0);
|
|
91
|
+
|
|
92
|
+
const byRow = useMemo<Record<string, SceneRowJobState>>(() => {
|
|
93
|
+
const mineByJob = new Map(
|
|
94
|
+
records
|
|
95
|
+
.filter((r) => r.station === STATION && r.kind === "generate_scene")
|
|
96
|
+
.map((r) => [r.jobId, r] as const),
|
|
97
|
+
);
|
|
98
|
+
const newestByRowKey = new Map<string, TrackedJob>();
|
|
99
|
+
for (const r of records) {
|
|
100
|
+
if (r.station !== STATION || r.kind !== "generate_scene" || !r.rowKey) continue;
|
|
101
|
+
const prev = newestByRowKey.get(r.rowKey);
|
|
102
|
+
if (!prev || r.enqueuedAt >= prev.enqueuedAt) newestByRowKey.set(r.rowKey, r);
|
|
103
|
+
}
|
|
104
|
+
const out: Record<string, SceneRowJobState> = {};
|
|
105
|
+
const rowIds = new Set([
|
|
106
|
+
...newestByRowKey.keys(),
|
|
107
|
+
...Object.keys(jobByRow),
|
|
108
|
+
...Object.keys(enqueueErrors),
|
|
109
|
+
...Object.keys(enqueuing),
|
|
110
|
+
...Object.keys(queued),
|
|
111
|
+
]);
|
|
112
|
+
for (const rowId of rowIds) {
|
|
113
|
+
const enqErr = enqueueErrors[rowId];
|
|
114
|
+
if (enqErr) {
|
|
115
|
+
out[rowId] = { ...IDLE, status: "failed", error: enqErr };
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
// Waiting its turn (sequential queue) and not yet submitted.
|
|
119
|
+
if (queued[rowId] && !jobByRow[rowId]) {
|
|
120
|
+
out[rowId] = { ...IDLE, queued: true, status: "queued" };
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// Enqueue window: submitted, no job_id yet — show running, keep Run disabled.
|
|
124
|
+
if (enqueuing[rowId] && !jobByRow[rowId]) {
|
|
125
|
+
out[rowId] = { ...IDLE, running: true };
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const jobId = jobByRow[rowId];
|
|
129
|
+
const rec =
|
|
130
|
+
(jobId ? mineByJob.get(jobId) : undefined) ?? newestByRowKey.get(rowId);
|
|
131
|
+
if (!rec) {
|
|
132
|
+
out[rowId] = { ...IDLE, running: !!enqueuing[rowId] };
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
out[rowId] = {
|
|
136
|
+
jobId: rec.jobId,
|
|
137
|
+
status: rec.status,
|
|
138
|
+
outputs: rec.outputs ?? [],
|
|
139
|
+
error:
|
|
140
|
+
rec.error ??
|
|
141
|
+
(rec.expired ? "This job is no longer tracked by the server." : null),
|
|
142
|
+
running: isJobActive(rec),
|
|
143
|
+
queued: false,
|
|
144
|
+
capped: rec.capped ?? false,
|
|
145
|
+
progress:
|
|
146
|
+
rec.progress && !isMovieProgress(rec.progress) ? rec.progress : null,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}, [records, jobByRow, enqueueErrors, enqueuing, queued]);
|
|
151
|
+
|
|
152
|
+
const resume = useCallback(
|
|
153
|
+
(rowId: string) => {
|
|
154
|
+
const jobId = byRow[rowId]?.jobId;
|
|
155
|
+
if (jobId) resumeJob(jobId);
|
|
156
|
+
},
|
|
157
|
+
[byRow],
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const anyRunning = useMemo(
|
|
161
|
+
() =>
|
|
162
|
+
Object.values(byRow).some(
|
|
163
|
+
(s: SceneRowJobState) => s.running || s.queued,
|
|
164
|
+
) || Object.keys(enqueuing).length > 0,
|
|
165
|
+
[byRow, enqueuing],
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const reset = useCallback(() => {
|
|
169
|
+
runSeq.current += 1; // stop any in-flight sequence
|
|
170
|
+
setJobByRow({});
|
|
171
|
+
setEnqueueErrors({});
|
|
172
|
+
setEnqueuing({});
|
|
173
|
+
setQueued({});
|
|
174
|
+
dismissTerminal(STATION);
|
|
175
|
+
}, []);
|
|
176
|
+
|
|
177
|
+
const runRows = useCallback(
|
|
178
|
+
(rows: { rowId: string; req: GenerateSceneRequest }[]) => {
|
|
179
|
+
if (rows.length === 0) return;
|
|
180
|
+
const myRun = ++runSeq.current;
|
|
181
|
+
setJobByRow({});
|
|
182
|
+
setEnqueueErrors({});
|
|
183
|
+
setEnqueuing({});
|
|
184
|
+
// Everything starts queued; each row leaves the queue when its turn comes.
|
|
185
|
+
setQueued(Object.fromEntries(rows.map((r) => [r.rowId, true])));
|
|
186
|
+
|
|
187
|
+
const waitTerminal = async (jobId: string) => {
|
|
188
|
+
const start = Date.now();
|
|
189
|
+
while (Date.now() - start < WAIT_CAP_MS) {
|
|
190
|
+
if (runSeq.current !== myRun) return; // superseded by a newer run/reset
|
|
191
|
+
const rec = recordsRef.current.find(
|
|
192
|
+
(r) => r.station === STATION && r.jobId === jobId,
|
|
193
|
+
);
|
|
194
|
+
if (
|
|
195
|
+
rec &&
|
|
196
|
+
(rec.status === "done" ||
|
|
197
|
+
rec.status === "failed" ||
|
|
198
|
+
rec.expired ||
|
|
199
|
+
rec.capped)
|
|
200
|
+
)
|
|
201
|
+
return;
|
|
202
|
+
await sleep(1200);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
void (async () => {
|
|
207
|
+
for (const { rowId, req } of rows) {
|
|
208
|
+
if (runSeq.current !== myRun) return;
|
|
209
|
+
// This row's turn: leave the queue, mark enqueuing.
|
|
210
|
+
setQueued((prev) => {
|
|
211
|
+
const n = { ...prev };
|
|
212
|
+
delete n[rowId];
|
|
213
|
+
return n;
|
|
214
|
+
});
|
|
215
|
+
setEnqueuing((prev) => ({ ...prev, [rowId]: true }));
|
|
216
|
+
const enqueuedAt = Date.now();
|
|
217
|
+
let jobId: string | null = null;
|
|
218
|
+
try {
|
|
219
|
+
const r = await request<unknown>(hugpyConfig.generateSceneEnqueueUrl, {
|
|
220
|
+
method: "POST",
|
|
221
|
+
body: JSON.stringify(req),
|
|
222
|
+
headers: { "Content-Type": "application/json" },
|
|
223
|
+
meta: { specKey: STATION, operation: "generate_scene.enqueue" },
|
|
224
|
+
});
|
|
225
|
+
setEnqueuing((prev) => {
|
|
226
|
+
const n = { ...prev };
|
|
227
|
+
delete n[rowId];
|
|
228
|
+
return n;
|
|
229
|
+
});
|
|
230
|
+
if (!r.ok) {
|
|
231
|
+
setEnqueueErrors((prev) => ({
|
|
232
|
+
...prev,
|
|
233
|
+
[rowId]: describeAppError(errorOf(r)),
|
|
234
|
+
}));
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const parsed = enqueueResultSchema.safeParse(okValue(r));
|
|
238
|
+
if (!parsed.success) {
|
|
239
|
+
setEnqueueErrors((prev) => ({
|
|
240
|
+
...prev,
|
|
241
|
+
[rowId]: "Malformed enqueue response.",
|
|
242
|
+
}));
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
jobId = parsed.data.job_id;
|
|
246
|
+
trackJob({
|
|
247
|
+
jobId,
|
|
248
|
+
rowKey: rowId,
|
|
249
|
+
kind: "generate_scene",
|
|
250
|
+
station: STATION,
|
|
251
|
+
label: "scene part",
|
|
252
|
+
enqueuedAt,
|
|
253
|
+
nFrames: req.n_frames,
|
|
254
|
+
chain: req.chain,
|
|
255
|
+
prompt: promptSummary(req.parts),
|
|
256
|
+
model: req.model_id,
|
|
257
|
+
});
|
|
258
|
+
const jid = jobId;
|
|
259
|
+
setJobByRow((prev) => ({ ...prev, [rowId]: jid }));
|
|
260
|
+
} catch (e) {
|
|
261
|
+
setEnqueuing((prev) => {
|
|
262
|
+
const n = { ...prev };
|
|
263
|
+
delete n[rowId];
|
|
264
|
+
return n;
|
|
265
|
+
});
|
|
266
|
+
setEnqueueErrors((prev) => ({
|
|
267
|
+
...prev,
|
|
268
|
+
[rowId]: e instanceof Error ? e.message : String(e),
|
|
269
|
+
}));
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
// SEQUENTIAL: block until this model's job is terminal before the next,
|
|
273
|
+
// so each generation gets a clean GPU (no simultaneous-load contention).
|
|
274
|
+
await waitTerminal(jobId);
|
|
275
|
+
}
|
|
276
|
+
})();
|
|
277
|
+
},
|
|
278
|
+
[],
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
return { byRow, anyRunning, runRows, resume, reset };
|
|
282
|
+
}
|