@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,283 @@
|
|
|
1
|
+
// src/stations/studio/useReconstruction.ts
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { request, okValue, errorOf, describeAppError } from "../../transport/client";
|
|
5
|
+
import {
|
|
6
|
+
jobStatusUrl,
|
|
7
|
+
identityReconstructionUrl,
|
|
8
|
+
identityCanonicalUrl,
|
|
9
|
+
} from "../../config";
|
|
10
|
+
import { jobRecordSchema, isTerminal } from "../../video/contract";
|
|
11
|
+
import type { JobStatus } from "../../video/contract";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_VIEWS = ["front", "three_quarter", "profile", "back"] as const;
|
|
14
|
+
export type ViewName = (typeof DEFAULT_VIEWS)[number];
|
|
15
|
+
|
|
16
|
+
export const VIEW_LABELS: Record<string, string> = {
|
|
17
|
+
front: "Front",
|
|
18
|
+
three_quarter: "¾ view",
|
|
19
|
+
profile: "Profile",
|
|
20
|
+
back: "Back",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const reconEnqueueSchema = z
|
|
24
|
+
.object({
|
|
25
|
+
recon_id: z.string().optional(),
|
|
26
|
+
job_id: z.union([z.string(), z.array(z.string())]).optional(),
|
|
27
|
+
job_ids: z.array(z.string()).optional(),
|
|
28
|
+
})
|
|
29
|
+
.passthrough();
|
|
30
|
+
|
|
31
|
+
export interface ReconstructionRun {
|
|
32
|
+
prompt?: string;
|
|
33
|
+
views?: string[];
|
|
34
|
+
seed?: number;
|
|
35
|
+
mode?: "sheet" | "turntable" | "angle-ring";
|
|
36
|
+
angle_step_deg?: number;
|
|
37
|
+
elevations_deg?: number[];
|
|
38
|
+
reference_images?: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PromoteResult {
|
|
42
|
+
ok: boolean;
|
|
43
|
+
message?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ReconstructionApi {
|
|
47
|
+
running: boolean;
|
|
48
|
+
jobIds: string[];
|
|
49
|
+
reconId: string | null;
|
|
50
|
+
done: number;
|
|
51
|
+
total: number;
|
|
52
|
+
error: string | null;
|
|
53
|
+
|
|
54
|
+
generate: (run: ReconstructionRun) => void;
|
|
55
|
+
promote: (reconId: string, viewIndices: number[]) => Promise<PromoteResult>;
|
|
56
|
+
reset: () => void;
|
|
57
|
+
|
|
58
|
+
// Angle-ring specific operations
|
|
59
|
+
setViewStatus: (reconId: string, viewId: string, status: "approved" | "rejected") => Promise<PromoteResult>;
|
|
60
|
+
regenerateView: (reconId: string, viewId: string, prompt?: string, seed?: number, useNeighbors?: boolean) => void;
|
|
61
|
+
buildMesh: (reconId: string, viewIds: string[]) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function useReconstruction(slug: string, onComplete: () => void): ReconstructionApi {
|
|
65
|
+
const [running, setRunning] = useState(false);
|
|
66
|
+
const [jobIds, setJobIds] = useState<string[]>([]);
|
|
67
|
+
const [statuses, setStatuses] = useState<Record<string, JobStatus>>({});
|
|
68
|
+
const [reconId, setReconId] = useState<string | null>(null);
|
|
69
|
+
const [error, setError] = useState<string | null>(null);
|
|
70
|
+
|
|
71
|
+
const mounted = useRef(true);
|
|
72
|
+
const timer = useRef<number | null>(null);
|
|
73
|
+
const idsRef = useRef<string[]>([]);
|
|
74
|
+
const statusesRef = useRef<Record<string, JobStatus>>({});
|
|
75
|
+
const onCompleteRef = useRef(onComplete);
|
|
76
|
+
onCompleteRef.current = onComplete;
|
|
77
|
+
|
|
78
|
+
const stopPoll = useCallback(() => {
|
|
79
|
+
if (timer.current != null) {
|
|
80
|
+
window.clearInterval(timer.current);
|
|
81
|
+
timer.current = null;
|
|
82
|
+
}
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
mounted.current = true;
|
|
87
|
+
return () => {
|
|
88
|
+
mounted.current = false;
|
|
89
|
+
stopPoll();
|
|
90
|
+
};
|
|
91
|
+
}, [stopPoll]);
|
|
92
|
+
|
|
93
|
+
const poll = useCallback(async () => {
|
|
94
|
+
const ids = idsRef.current;
|
|
95
|
+
if (ids.length === 0) return;
|
|
96
|
+
const records = await Promise.all(
|
|
97
|
+
ids.map(async (id) => {
|
|
98
|
+
const res = await request<unknown>(jobStatusUrl(id), {
|
|
99
|
+
meta: { specKey: "studio", operation: "identity.reconstruction.status" },
|
|
100
|
+
});
|
|
101
|
+
if (!res.ok) return null;
|
|
102
|
+
const parsed = jobRecordSchema.safeParse(okValue(res));
|
|
103
|
+
return parsed.success ? parsed.data : null;
|
|
104
|
+
}),
|
|
105
|
+
);
|
|
106
|
+
if (!mounted.current) return;
|
|
107
|
+
const merged: Record<string, JobStatus> = { ...statusesRef.current };
|
|
108
|
+
let firstError: string | null = null;
|
|
109
|
+
records.forEach((jr, i) => {
|
|
110
|
+
if (!jr) return;
|
|
111
|
+
merged[ids[i]] = jr.status ?? null;
|
|
112
|
+
if ((jr.status === "failed" || jr.status === "cancelled") && firstError == null) {
|
|
113
|
+
firstError = jr.result?.error?.message ?? `Job ${jr.status}.`;
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
statusesRef.current = merged;
|
|
117
|
+
setStatuses(merged);
|
|
118
|
+
if (firstError != null) setError(firstError);
|
|
119
|
+
if (ids.every((id) => isTerminal(merged[id] ?? null))) {
|
|
120
|
+
stopPoll();
|
|
121
|
+
setRunning(false);
|
|
122
|
+
onCompleteRef.current();
|
|
123
|
+
}
|
|
124
|
+
}, [stopPoll]);
|
|
125
|
+
|
|
126
|
+
// Shared response handler for anything that enqueues jobs (generate, regenerate, buildMesh)
|
|
127
|
+
const handleEnqueueResponse = useCallback((r: Awaited<ReturnType<typeof request<unknown>>>) => {
|
|
128
|
+
if (!mounted.current) return;
|
|
129
|
+
if (!r.ok) {
|
|
130
|
+
setError(describeAppError(errorOf(r)));
|
|
131
|
+
setRunning(false);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const parsed = reconEnqueueSchema.safeParse(okValue(r));
|
|
135
|
+
if (!parsed.success) {
|
|
136
|
+
setError("Malformed reconstruction response.");
|
|
137
|
+
setRunning(false);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const d = parsed.data;
|
|
141
|
+
if (d.recon_id) setReconId(d.recon_id);
|
|
142
|
+
const ids: string[] = [];
|
|
143
|
+
if (typeof d.job_id === "string") ids.push(d.job_id);
|
|
144
|
+
else if (Array.isArray(d.job_id)) ids.push(...d.job_id);
|
|
145
|
+
if (Array.isArray(d.job_ids)) ids.push(...d.job_ids);
|
|
146
|
+
const uniq = Array.from(new Set(ids.filter((x) => typeof x === "string" && x !== "")));
|
|
147
|
+
if (uniq.length === 0) {
|
|
148
|
+
setRunning(false);
|
|
149
|
+
onCompleteRef.current();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
idsRef.current = uniq;
|
|
153
|
+
setJobIds(uniq);
|
|
154
|
+
const seeded: Record<string, JobStatus> = {};
|
|
155
|
+
uniq.forEach((id) => (seeded[id] = "queued"));
|
|
156
|
+
statusesRef.current = seeded;
|
|
157
|
+
setStatuses(seeded);
|
|
158
|
+
void poll();
|
|
159
|
+
timer.current = window.setInterval(() => void poll(), 2000);
|
|
160
|
+
}, [poll]);
|
|
161
|
+
|
|
162
|
+
const generate = useCallback(
|
|
163
|
+
(run: ReconstructionRun) => {
|
|
164
|
+
stopPoll();
|
|
165
|
+
setError(null);
|
|
166
|
+
setReconId(null);
|
|
167
|
+
setJobIds([]);
|
|
168
|
+
setStatuses({});
|
|
169
|
+
statusesRef.current = {};
|
|
170
|
+
idsRef.current = [];
|
|
171
|
+
setRunning(true);
|
|
172
|
+
|
|
173
|
+
const body: Record<string, unknown> = {};
|
|
174
|
+
if (run.prompt != null && run.prompt.trim() !== "") body.prompt = run.prompt.trim();
|
|
175
|
+
if (run.views && run.views.length > 0) body.views = run.views;
|
|
176
|
+
if (run.seed != null && Number.isFinite(run.seed)) body.seed = run.seed;
|
|
177
|
+
if (run.mode) body.mode = run.mode;
|
|
178
|
+
if (run.angle_step_deg != null) body.angle_step_deg = run.angle_step_deg;
|
|
179
|
+
if (run.elevations_deg != null) body.elevations_deg = run.elevations_deg;
|
|
180
|
+
if (run.reference_images != null) body.reference_images = run.reference_images;
|
|
181
|
+
|
|
182
|
+
void request<unknown>(identityReconstructionUrl(slug), {
|
|
183
|
+
method: "POST",
|
|
184
|
+
body: JSON.stringify(body),
|
|
185
|
+
headers: { "Content-Type": "application/json" },
|
|
186
|
+
meta: { specKey: "studio", operation: "identity.reconstruction.enqueue" },
|
|
187
|
+
}).then(handleEnqueueResponse);
|
|
188
|
+
},
|
|
189
|
+
[slug, handleEnqueueResponse, stopPoll],
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const setViewStatus = useCallback(
|
|
193
|
+
async (rid: string, viewId: string, status: "approved" | "rejected"): Promise<PromoteResult> => {
|
|
194
|
+
const base = identityReconstructionUrl(slug);
|
|
195
|
+
const res = await request<unknown>(`${base}/${rid}/views/${viewId}`, {
|
|
196
|
+
method: "PATCH",
|
|
197
|
+
body: JSON.stringify({ status }),
|
|
198
|
+
headers: { "Content-Type": "application/json" },
|
|
199
|
+
meta: { specKey: "studio", operation: "identity.view.status" },
|
|
200
|
+
});
|
|
201
|
+
if (!res.ok) return { ok: false, message: describeAppError(errorOf(res)) };
|
|
202
|
+
onCompleteRef.current(); // reload profile so UI marks it approved immediately
|
|
203
|
+
return { ok: true };
|
|
204
|
+
},
|
|
205
|
+
[slug]
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const regenerateView = useCallback(
|
|
209
|
+
(rid: string, viewId: string, prompt?: string, seed?: number, useNeighbors: boolean = true) => {
|
|
210
|
+
stopPoll();
|
|
211
|
+
setError(null);
|
|
212
|
+
setJobIds([]);
|
|
213
|
+
setStatuses({});
|
|
214
|
+
statusesRef.current = {};
|
|
215
|
+
idsRef.current = [];
|
|
216
|
+
setRunning(true);
|
|
217
|
+
|
|
218
|
+
const body: Record<string, unknown> = { use_nearest_approved_neighbors: useNeighbors };
|
|
219
|
+
if (prompt) body.prompt = prompt.trim();
|
|
220
|
+
if (seed != null) body.seed = seed;
|
|
221
|
+
|
|
222
|
+
const base = identityReconstructionUrl(slug);
|
|
223
|
+
void request<unknown>(`${base}/${rid}/views/${viewId}/regenerate`, {
|
|
224
|
+
method: "POST",
|
|
225
|
+
body: JSON.stringify(body),
|
|
226
|
+
headers: { "Content-Type": "application/json" },
|
|
227
|
+
meta: { specKey: "studio", operation: "identity.view.regenerate" },
|
|
228
|
+
}).then(handleEnqueueResponse);
|
|
229
|
+
},
|
|
230
|
+
[slug, handleEnqueueResponse, stopPoll]
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const buildMesh = useCallback(
|
|
234
|
+
(rid: string, viewIds: string[]) => {
|
|
235
|
+
stopPoll();
|
|
236
|
+
setError(null);
|
|
237
|
+
setJobIds([]);
|
|
238
|
+
setStatuses({});
|
|
239
|
+
statusesRef.current = {};
|
|
240
|
+
idsRef.current = [];
|
|
241
|
+
setRunning(true);
|
|
242
|
+
|
|
243
|
+
const base = identityReconstructionUrl(slug);
|
|
244
|
+
void request<unknown>(`${base}/${rid}/mesh`, {
|
|
245
|
+
method: "POST",
|
|
246
|
+
body: JSON.stringify({ views: viewIds }),
|
|
247
|
+
headers: { "Content-Type": "application/json" },
|
|
248
|
+
meta: { specKey: "studio", operation: "identity.mesh.build" },
|
|
249
|
+
}).then(handleEnqueueResponse);
|
|
250
|
+
},
|
|
251
|
+
[slug, handleEnqueueResponse, stopPoll]
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const promote = useCallback(
|
|
255
|
+
async (rid: string, viewIndices: number[]): Promise<PromoteResult> => {
|
|
256
|
+
const res = await request<unknown>(identityCanonicalUrl(slug), {
|
|
257
|
+
method: "POST",
|
|
258
|
+
body: JSON.stringify({ recon_id: rid, views: viewIndices }),
|
|
259
|
+
headers: { "Content-Type": "application/json" },
|
|
260
|
+
meta: { specKey: "studio", operation: "identity.canonical.promote" },
|
|
261
|
+
});
|
|
262
|
+
if (!res.ok) return { ok: false, message: describeAppError(errorOf(res)) };
|
|
263
|
+
return { ok: true };
|
|
264
|
+
},
|
|
265
|
+
[slug]
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const reset = useCallback(() => {
|
|
269
|
+
stopPoll();
|
|
270
|
+
setRunning(false);
|
|
271
|
+
setJobIds([]);
|
|
272
|
+
setStatuses({});
|
|
273
|
+
statusesRef.current = {};
|
|
274
|
+
idsRef.current = [];
|
|
275
|
+
setReconId(null);
|
|
276
|
+
setError(null);
|
|
277
|
+
}, [stopPoll]);
|
|
278
|
+
|
|
279
|
+
const total = jobIds.length;
|
|
280
|
+
const done = jobIds.filter((id) => isTerminal(statuses[id] ?? null)).length;
|
|
281
|
+
|
|
282
|
+
return { running, jobIds, reconId, done, total, error, generate, setViewStatus, regenerateView, buildMesh, promote, reset };
|
|
283
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Station contract for the video-intelligence arm — the PageSpec analog at this
|
|
2
|
+
// layer. A station is a capability page: it consumes MediaRefs, builds ONE frozen
|
|
3
|
+
// spec type, enqueues it on the job bus, and subscribes to results by job_id.
|
|
4
|
+
// The registry (registry.ts) is the single place stations are declared; the
|
|
5
|
+
// shell renders whatever is registered. Registries over globals, top to bottom.
|
|
6
|
+
import type { ComponentType } from "react";
|
|
7
|
+
|
|
8
|
+
export type StationStatus = "planned" | "active";
|
|
9
|
+
|
|
10
|
+
export interface StationSpec {
|
|
11
|
+
/** Stable id — also the route segment (/#/<id> equivalent under the router). */
|
|
12
|
+
id: string;
|
|
13
|
+
/** Tab label, matching the sample image's top-tab affordance. */
|
|
14
|
+
title: string;
|
|
15
|
+
/** One-line purpose shown on the placeholder card. */
|
|
16
|
+
blurb: string;
|
|
17
|
+
/** ROADMAP.md phase that turns this station real. */
|
|
18
|
+
phase: string;
|
|
19
|
+
/** Backend spec type this station will build (from the architecture map). */
|
|
20
|
+
buildsSpec: string;
|
|
21
|
+
/** Planned affordances, rendered as the placeholder checklist. */
|
|
22
|
+
planned: string[];
|
|
23
|
+
/**
|
|
24
|
+
* Workbench group id. Every built station now shares the single "studio"
|
|
25
|
+
* group and appears as a sub-tab of the one workbench (the old Studio/Generate
|
|
26
|
+
* top-level split is retired). Kept optional so a future station can opt out.
|
|
27
|
+
*/
|
|
28
|
+
group?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Hide this station from the sub-tab NAV strip while keeping its ROUTE live —
|
|
31
|
+
* WorkbenchStation still resolves /<id>, so deep-links (e.g. the sidebar's
|
|
32
|
+
* "Send to Studio") keep working. Retires a redundant tab from the nav without
|
|
33
|
+
* deleting the page.
|
|
34
|
+
*/
|
|
35
|
+
navHidden?: boolean;
|
|
36
|
+
status: StationStatus;
|
|
37
|
+
/**
|
|
38
|
+
* The real body, once the station is built. When present AND status==="active"
|
|
39
|
+
* the shell renders this instead of the PlaceholderStation. Receives its own
|
|
40
|
+
* StationSpec so a component can read its id/title/phase.
|
|
41
|
+
*/
|
|
42
|
+
component?: ComponentType<{ spec: StationSpec }>;
|
|
43
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// The Audio Crop station's enqueue surface — the temporal sibling of
|
|
2
|
+
// useCropJobs. It still owns the request-body construction (one TEMPORAL crop
|
|
3
|
+
// job per queued window, reusing the crop endpoint with spatial: null) and POSTs
|
|
4
|
+
// each through request<T>() + config URLs; the moment it has a job_id it hands
|
|
5
|
+
// the job to jobTracker.trackJob and stops owning any polling or job state.
|
|
6
|
+
// Polling, the poll cap, the terminal parse, and the single library push all
|
|
7
|
+
// live in jobTracker now — so a clip enqueued here keeps running (and lands in
|
|
8
|
+
// the library, origin "audio-crop") even after the user switches sub-tabs
|
|
9
|
+
// (which unmounts this station) or reloads the page.
|
|
10
|
+
//
|
|
11
|
+
// The public API to AudioCropStation is UNCHANGED: `{ jobs, runCrops, resume,
|
|
12
|
+
// reset }` where each job carries { key, index, region, jobId, status, output,
|
|
13
|
+
// error, capped, startedAt }. `jobs` is DERIVED from the tracker (this station's
|
|
14
|
+
// crop records) merged with any still-enqueuing local placeholders.
|
|
15
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
16
|
+
import { request, okValue, errorOf, describeAppError } from "../transport/client";
|
|
17
|
+
import { hugpyConfig } from "../config";
|
|
18
|
+
import {
|
|
19
|
+
enqueueResultSchema,
|
|
20
|
+
type CropJobRequest,
|
|
21
|
+
type JobStatus,
|
|
22
|
+
type MediaRef,
|
|
23
|
+
} from "../video/contract";
|
|
24
|
+
import {
|
|
25
|
+
useJobTracker,
|
|
26
|
+
trackJob,
|
|
27
|
+
resumeJob,
|
|
28
|
+
resumeStation,
|
|
29
|
+
dismissTerminal,
|
|
30
|
+
type TrackedJob as TrackerRecord,
|
|
31
|
+
} from "../video/jobTracker";
|
|
32
|
+
import type { TemporalRegion } from "../regions/types";
|
|
33
|
+
|
|
34
|
+
export interface TrackedAudioJob {
|
|
35
|
+
/** Stable local key (the server job id once enqueued). */
|
|
36
|
+
key: string;
|
|
37
|
+
/** 1-based position, for display. */
|
|
38
|
+
index: number;
|
|
39
|
+
/** The temporal window (seconds) this job crops. */
|
|
40
|
+
region: TemporalRegion;
|
|
41
|
+
/** Server job id once enqueued, else null. */
|
|
42
|
+
jobId: string | null;
|
|
43
|
+
/** Latest polled status (null = enqueued but not yet claimed). */
|
|
44
|
+
status: JobStatus;
|
|
45
|
+
/** Result audio-clip MediaRef on success. */
|
|
46
|
+
output: MediaRef | null;
|
|
47
|
+
/** Human message on failure (enqueue error or job error). */
|
|
48
|
+
error: string | null;
|
|
49
|
+
/** Poll cap reached while non-terminal — NOT a failure (Phase 8). */
|
|
50
|
+
capped: boolean;
|
|
51
|
+
/** Enqueue timestamp — the anchor for the poll cap. */
|
|
52
|
+
startedAt: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A pre-enqueue placeholder — see useCropJobs.ts for the rationale.
|
|
56
|
+
interface Pending {
|
|
57
|
+
key: string;
|
|
58
|
+
index: number;
|
|
59
|
+
region: TemporalRegion;
|
|
60
|
+
status: Extract<JobStatus, "queued" | "failed">;
|
|
61
|
+
error: string | null;
|
|
62
|
+
startedAt: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let _seq = 0;
|
|
66
|
+
function makeKey(): string {
|
|
67
|
+
_seq += 1;
|
|
68
|
+
return `aclip_${_seq.toString(36)}_${Date.now().toString(36)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface AudioCropJobsApi {
|
|
72
|
+
jobs: TrackedAudioJob[];
|
|
73
|
+
/** Enqueue one temporal crop job per region and begin polling (via the tracker). */
|
|
74
|
+
runCrops: (regions: TemporalRegion[]) => void;
|
|
75
|
+
/**
|
|
76
|
+
* Re-anchor the poll clock for a capped job (or every capped clip when `key`
|
|
77
|
+
* is omitted) and resume polling. Clears `capped`, keeps status.
|
|
78
|
+
*/
|
|
79
|
+
resume: (key?: string) => void;
|
|
80
|
+
/** Forget this station's finished jobs (called when a new source is ingested). */
|
|
81
|
+
reset: () => void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const STATION = "audio-crop";
|
|
85
|
+
|
|
86
|
+
export function useAudioCropJobs(source: MediaRef | null): AudioCropJobsApi {
|
|
87
|
+
const records = useJobTracker();
|
|
88
|
+
const [pending, setPending] = useState<Pending[]>([]);
|
|
89
|
+
|
|
90
|
+
const mine = useMemo(
|
|
91
|
+
() => records.filter((r) => r.station === STATION && r.kind === "crop"),
|
|
92
|
+
[records],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const mineRef = useRef<TrackerRecord[]>(mine);
|
|
96
|
+
mineRef.current = mine;
|
|
97
|
+
const pendingRef = useRef<Pending[]>(pending);
|
|
98
|
+
pendingRef.current = pending;
|
|
99
|
+
|
|
100
|
+
const jobs = useMemo<TrackedAudioJob[]>(() => {
|
|
101
|
+
const fromTracker: TrackedAudioJob[] = mine.map((r) => ({
|
|
102
|
+
key: r.jobId,
|
|
103
|
+
index: r.index,
|
|
104
|
+
region: (r.region as TemporalRegion) ?? { start_s: 0, end_s: 0 },
|
|
105
|
+
jobId: r.jobId,
|
|
106
|
+
status: r.status,
|
|
107
|
+
output: r.outputs[0] ?? null,
|
|
108
|
+
error: r.error,
|
|
109
|
+
capped: r.capped,
|
|
110
|
+
startedAt: r.startedAt,
|
|
111
|
+
}));
|
|
112
|
+
const fromPending: TrackedAudioJob[] = pending.map((p) => ({
|
|
113
|
+
key: p.key,
|
|
114
|
+
index: p.index,
|
|
115
|
+
region: p.region,
|
|
116
|
+
jobId: null,
|
|
117
|
+
status: p.status,
|
|
118
|
+
output: null,
|
|
119
|
+
error: p.error,
|
|
120
|
+
capped: false,
|
|
121
|
+
startedAt: p.startedAt,
|
|
122
|
+
}));
|
|
123
|
+
return [...fromTracker, ...fromPending].sort((a, b) => a.index - b.index);
|
|
124
|
+
}, [mine, pending]);
|
|
125
|
+
|
|
126
|
+
const reset = useCallback(() => {
|
|
127
|
+
setPending([]);
|
|
128
|
+
dismissTerminal(STATION);
|
|
129
|
+
}, []);
|
|
130
|
+
|
|
131
|
+
const resume = useCallback((key?: string) => {
|
|
132
|
+
if (key != null) resumeJob(key);
|
|
133
|
+
else resumeStation(STATION);
|
|
134
|
+
}, []);
|
|
135
|
+
|
|
136
|
+
const runCrops = useCallback(
|
|
137
|
+
(regions: TemporalRegion[]) => {
|
|
138
|
+
if (!source || regions.length === 0) return;
|
|
139
|
+
const base = Math.max(
|
|
140
|
+
0,
|
|
141
|
+
...mineRef.current.map((r) => r.index),
|
|
142
|
+
...pendingRef.current.map((p) => p.index),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
regions.forEach((region, i) => {
|
|
146
|
+
const key = makeKey();
|
|
147
|
+
const index = base + i + 1;
|
|
148
|
+
const enqueuedAt = Date.now();
|
|
149
|
+
const label = `clip ${region.start_s.toFixed(2)}–${region.end_s.toFixed(2)}s`;
|
|
150
|
+
setPending((prev) => [
|
|
151
|
+
...prev,
|
|
152
|
+
{ key, index, region, status: "queued", error: null, startedAt: enqueuedAt },
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
// Temporal audio crop reuses the crop endpoint — spatial is null.
|
|
156
|
+
const body: CropJobRequest = { source, spatial: null, temporal: region };
|
|
157
|
+
void request<unknown>(hugpyConfig.cropEnqueueUrl, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
body: JSON.stringify(body),
|
|
160
|
+
headers: { "Content-Type": "application/json" },
|
|
161
|
+
meta: { specKey: STATION, operation: "crop.enqueue" },
|
|
162
|
+
}).then((r) => {
|
|
163
|
+
if (!r.ok) {
|
|
164
|
+
setPending((prev) =>
|
|
165
|
+
prev.map((p) =>
|
|
166
|
+
p.key === key
|
|
167
|
+
? { ...p, status: "failed", error: describeAppError(errorOf(r)) }
|
|
168
|
+
: p,
|
|
169
|
+
),
|
|
170
|
+
);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const parsed = enqueueResultSchema.safeParse(okValue(r));
|
|
174
|
+
if (!parsed.success) {
|
|
175
|
+
setPending((prev) =>
|
|
176
|
+
prev.map((p) =>
|
|
177
|
+
p.key === key
|
|
178
|
+
? { ...p, status: "failed", error: "Malformed enqueue response." }
|
|
179
|
+
: p,
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
trackJob({
|
|
185
|
+
jobId: parsed.data.job_id,
|
|
186
|
+
kind: "crop",
|
|
187
|
+
station: STATION,
|
|
188
|
+
label,
|
|
189
|
+
libLabel: label,
|
|
190
|
+
enqueuedAt,
|
|
191
|
+
index,
|
|
192
|
+
region,
|
|
193
|
+
});
|
|
194
|
+
setPending((prev) => prev.filter((p) => p.key !== key));
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
[source],
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
return { jobs, runCrops, resume, reset };
|
|
202
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// The Audio Crop station's video→audio step, now riding the session-wide job
|
|
2
|
+
// tracker. When the ingested source is a VIDEO we pull its audio track out into
|
|
3
|
+
// a standalone audio MediaRef before the temporal editor can open. This hook
|
|
4
|
+
// still owns the request-body construction (fmt "wav") 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
|
+
// poll cap, and the terminal parse all live in jobTracker now — so the extract
|
|
8
|
+
// keeps running even after a sub-tab switch or a page reload.
|
|
9
|
+
//
|
|
10
|
+
// The extracted audio is INTERMEDIATE (it feeds the temporal editor), so the
|
|
11
|
+
// tracker deliberately does NOT push it to the media library — only the finished
|
|
12
|
+
// crops are (see useAudioCropJobs). It is still tracked/polled so the process is
|
|
13
|
+
// visible and survives tab switches.
|
|
14
|
+
//
|
|
15
|
+
// The public API to AudioCropStation is UNCHANGED:
|
|
16
|
+
// `{ jobId, status, output, error, capped, running, run, resume, reset }`.
|
|
17
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
18
|
+
import { request, okValue, errorOf, describeAppError } from "../transport/client";
|
|
19
|
+
import { hugpyConfig } from "../config";
|
|
20
|
+
import {
|
|
21
|
+
enqueueResultSchema,
|
|
22
|
+
type AudioExtractRequest,
|
|
23
|
+
type JobStatus,
|
|
24
|
+
type MediaRef,
|
|
25
|
+
} from "../video/contract";
|
|
26
|
+
import {
|
|
27
|
+
useJobTracker,
|
|
28
|
+
trackJob,
|
|
29
|
+
resumeJob,
|
|
30
|
+
dismissTerminal,
|
|
31
|
+
isJobActive,
|
|
32
|
+
} from "../video/jobTracker";
|
|
33
|
+
|
|
34
|
+
export interface AudioExtractJobApi {
|
|
35
|
+
/** Server job id once enqueued, else null. */
|
|
36
|
+
jobId: string | null;
|
|
37
|
+
/** Latest polled status (null = idle or enqueued-but-not-yet-claimed). */
|
|
38
|
+
status: JobStatus;
|
|
39
|
+
/** The extracted audio MediaRef (populated on done). */
|
|
40
|
+
output: MediaRef | null;
|
|
41
|
+
/** Human message on failure (enqueue error or job error). */
|
|
42
|
+
error: string | null;
|
|
43
|
+
/** Poll cap reached while non-terminal (Phase 8) — NOT a failure. */
|
|
44
|
+
capped: boolean;
|
|
45
|
+
/** True while enqueuing or polling a non-terminal job. */
|
|
46
|
+
running: boolean;
|
|
47
|
+
/** Enqueue ONE audio_extract job for a video source (fmt "wav") and poll. */
|
|
48
|
+
run: (source: MediaRef) => void;
|
|
49
|
+
/** Re-anchor the poll clock and resume polling a capped job. Clears `capped`. */
|
|
50
|
+
resume: () => void;
|
|
51
|
+
/** Forget this station's finished extract (called when a new source is ingested). */
|
|
52
|
+
reset: () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const STATION = "audio-crop";
|
|
56
|
+
|
|
57
|
+
export function useAudioExtractJob(): AudioExtractJobApi {
|
|
58
|
+
const records = useJobTracker();
|
|
59
|
+
const [currentJobId, setCurrentJobId] = useState<string | null>(null);
|
|
60
|
+
const [enqueuing, setEnqueuing] = useState<boolean>(false);
|
|
61
|
+
const [enqueueError, setEnqueueError] = useState<string | null>(null);
|
|
62
|
+
|
|
63
|
+
// Only the audio_extract kind — the crop kind on this same station is owned by
|
|
64
|
+
// useAudioCropJobs, so the two never cross-derive.
|
|
65
|
+
const record = useMemo(() => {
|
|
66
|
+
const mine = records.filter(
|
|
67
|
+
(r) => r.station === STATION && r.kind === "audio_extract",
|
|
68
|
+
);
|
|
69
|
+
if (currentJobId) return mine.find((r) => r.jobId === currentJobId) ?? null;
|
|
70
|
+
if (enqueuing) return null;
|
|
71
|
+
return mine.slice().sort((a, b) => b.enqueuedAt - a.enqueuedAt)[0] ?? null;
|
|
72
|
+
}, [records, currentJobId, enqueuing]);
|
|
73
|
+
|
|
74
|
+
const jobIdRef = useRef<string | null>(record?.jobId ?? null);
|
|
75
|
+
jobIdRef.current = record?.jobId ?? null;
|
|
76
|
+
|
|
77
|
+
const reset = useCallback(() => {
|
|
78
|
+
setCurrentJobId(null);
|
|
79
|
+
setEnqueuing(false);
|
|
80
|
+
setEnqueueError(null);
|
|
81
|
+
// Only forget finished audio_extract rows — leave any in-flight crop of this
|
|
82
|
+
// station alone (useAudioCropJobs.reset handles the crop rows).
|
|
83
|
+
dismissTerminal(STATION);
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
const resume = useCallback(() => {
|
|
87
|
+
const id = jobIdRef.current;
|
|
88
|
+
if (id) resumeJob(id);
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
const run = useCallback((source: MediaRef) => {
|
|
92
|
+
// Fresh run — default fmt "wav" (lossless, trivially decodable for the waveform).
|
|
93
|
+
setCurrentJobId(null);
|
|
94
|
+
setEnqueueError(null);
|
|
95
|
+
setEnqueuing(true);
|
|
96
|
+
const enqueuedAt = Date.now();
|
|
97
|
+
|
|
98
|
+
const body: AudioExtractRequest = { source, fmt: "wav" };
|
|
99
|
+
void request<unknown>(hugpyConfig.audioExtractEnqueueUrl, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
body: JSON.stringify(body),
|
|
102
|
+
headers: { "Content-Type": "application/json" },
|
|
103
|
+
meta: { specKey: STATION, operation: "audio_extract.enqueue" },
|
|
104
|
+
}).then((r) => {
|
|
105
|
+
setEnqueuing(false);
|
|
106
|
+
if (!r.ok) {
|
|
107
|
+
setEnqueueError(describeAppError(errorOf(r)));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const parsed = enqueueResultSchema.safeParse(okValue(r));
|
|
111
|
+
if (!parsed.success) {
|
|
112
|
+
setEnqueueError("Malformed enqueue response.");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const jobId = parsed.data.job_id;
|
|
116
|
+
trackJob({
|
|
117
|
+
jobId,
|
|
118
|
+
kind: "audio_extract",
|
|
119
|
+
station: STATION,
|
|
120
|
+
label: "extract audio track",
|
|
121
|
+
enqueuedAt,
|
|
122
|
+
});
|
|
123
|
+
setCurrentJobId(jobId);
|
|
124
|
+
});
|
|
125
|
+
}, []);
|
|
126
|
+
|
|
127
|
+
const status: JobStatus = enqueueError ? "failed" : record?.status ?? null;
|
|
128
|
+
const error =
|
|
129
|
+
enqueueError ??
|
|
130
|
+
record?.error ??
|
|
131
|
+
(record?.expired ? "This job is no longer tracked by the server." : null);
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
jobId: record?.jobId ?? null,
|
|
135
|
+
status,
|
|
136
|
+
output: record?.outputs[0] ?? null,
|
|
137
|
+
error,
|
|
138
|
+
capped: record?.capped ?? false,
|
|
139
|
+
running: enqueuing || (record != null && isJobActive(record)),
|
|
140
|
+
run,
|
|
141
|
+
resume,
|
|
142
|
+
reset,
|
|
143
|
+
};
|
|
144
|
+
}
|