@hugpy/video-intelligence-ui 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-C_av7KDW.js +4190 -0
- package/dist/assets/index-DMoT8G1q.css +1 -0
- package/dist/index.html +2 -2
- package/entry.tsx +6 -27
- package/package.json +3 -2
- package/src/config.ts +11 -27
- package/src/demo/demoFetch.ts +0 -9
- package/src/nav/Navbar.tsx +3 -3
- package/src/session.ts +8 -43
- package/src/stations/GenerateStation.tsx +8 -5
- package/src/stations/SharePanel.tsx +4 -4
- package/src/stations/ToksPanel.tsx +1 -2
- package/src/stations/WorkbenchSidebar.tsx +8 -261
- package/src/stations/ops/CharacterGroupsPanel.tsx +75 -83
- package/src/stations/studio/IdentitiesStation.tsx +66 -75
- package/src/stations/studio/StudioGenerateMode.tsx +3 -2
- package/src/stations/studio/StudioGenerateTab.tsx +258 -188
- package/src/stations/studio/StudioMovieComposer.tsx +320 -57
- package/src/stations/studio/StudioPlane.tsx +3 -1
- package/src/stations/studio/StudioViewer.tsx +6 -11
- package/src/stations/studio/studioShared.tsx +14 -2
- package/src/stations/studio/useIdentityCreateJob.ts +26 -21
- package/src/stations/studio/useReconstruction.ts +11 -5
- package/src/video/PromptAssistButtons.tsx +5 -6
- package/src/video/jobStream.ts +119 -0
- package/src/video/jobTracker.ts +67 -76
- package/src/video/modelsFeed.ts +105 -0
- package/src/video/studioOracle.ts +48 -0
- package/src/video/useMediaJobs.ts +17 -3
- package/src/video/useModels.ts +12 -53
- package/src/video/useMovieSessions.ts +15 -6
- package/src/video/useOperatorHistory.ts +11 -101
- package/src/video/usePromptAssist.ts +6 -3
- package/src/video/useToks.ts +14 -6
- package/src/video/watchVideoJob.ts +43 -0
- package/dist/assets/index-DuT1AMLn.css +0 -1
- package/dist/assets/index-_13ZiBDP.js +0 -4191
- package/src/video/useWorkerCalls.ts +0 -165
|
@@ -61,9 +61,10 @@ export interface StudioPlaneProps {
|
|
|
61
61
|
* that sub-mode and hide its internal Clip|Cinema switcher. Omitted by the standalone
|
|
62
62
|
* /studio-clips station, which keeps the internal switcher. */
|
|
63
63
|
lockedSurfaceMode?: "clip" | "movie";
|
|
64
|
+
onSelectSurfaceMode?: (mode: "clip" | "movie") => void;
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
export function StudioPlane({ registerBridge = false, lockedSurfaceMode }: StudioPlaneProps) {
|
|
67
|
+
export function StudioPlane({ registerBridge = false, lockedSurfaceMode, onSelectSurfaceMode }: StudioPlaneProps) {
|
|
67
68
|
const reg = useSidebarRegistry();
|
|
68
69
|
const { presets, loading: presetsLoading } = useStudioPresets(true);
|
|
69
70
|
const clipsState = useStudioClips();
|
|
@@ -341,6 +342,7 @@ export function StudioPlane({ registerBridge = false, lockedSurfaceMode }: Studi
|
|
|
341
342
|
onEnqueued={clipsState.refresh}
|
|
342
343
|
settingsHost={reg.settingsHost}
|
|
343
344
|
lockedSurfaceMode={lockedSurfaceMode}
|
|
345
|
+
onSelectSurfaceMode={onSelectSurfaceMode}
|
|
344
346
|
/>
|
|
345
347
|
</div>
|
|
346
348
|
{/* STUDIO-ASSIST LIVE LOG → portaled into the sidebar's ACTIVE (Active
|
|
@@ -109,18 +109,13 @@ export function StudioViewer({
|
|
|
109
109
|
const [sentMsg, setSentMsg] = useState<string | null>(null);
|
|
110
110
|
const [sendErr, setSendErr] = useState<string | null>(null);
|
|
111
111
|
|
|
112
|
+
// The operator probe (GET /keys/video-share) was dropped (2026-10-05): the
|
|
113
|
+
// route has no backend here, so it only 404'd — i.e. always resolved to
|
|
114
|
+
// "not operator". Set that outcome directly, with no dead request. (The
|
|
115
|
+
// Send-to-Editor target /video/studio/clip/<id>/to-editor is itself a 501 in
|
|
116
|
+
// this build, so the affordance has nothing to call anyway.)
|
|
112
117
|
useEffect(() => {
|
|
113
|
-
|
|
114
|
-
void (async () => {
|
|
115
|
-
const res = await request<unknown>(hugpyConfig.keysVideoShareUrl, {
|
|
116
|
-
method: "GET",
|
|
117
|
-
meta: { specKey: "studio", operation: "studio.operatorProbe" },
|
|
118
|
-
});
|
|
119
|
-
if (alive) setOperator(res.ok);
|
|
120
|
-
})();
|
|
121
|
-
return () => {
|
|
122
|
-
alive = false;
|
|
123
|
-
};
|
|
118
|
+
setOperator(false);
|
|
124
119
|
}, []);
|
|
125
120
|
|
|
126
121
|
// Clear any transient send message when the shown clip changes, so a "sent"
|
|
@@ -24,6 +24,7 @@ import { z } from "zod";
|
|
|
24
24
|
import { request, okValue, errorOf, describeAppError } from "../../transport/client";
|
|
25
25
|
import { hugpyConfig, studioClipDetailUrl, mediaBytesUrl } from "../../config";
|
|
26
26
|
import { addToLibrary, updateLibraryProvenance } from "../../video/mediaLibrary";
|
|
27
|
+
import { subscribeJobStream } from "../../video/jobStream";
|
|
27
28
|
import type { LibraryItem } from "../../video/mediaLibrary";
|
|
28
29
|
import type { MediaRef } from "../../video/contract";
|
|
29
30
|
import type { StudioPreset } from "../../video/useStudioPresets";
|
|
@@ -804,13 +805,24 @@ export function useStudioClips(): StudioClipsState {
|
|
|
804
805
|
}
|
|
805
806
|
}, [backfillProvenance]);
|
|
806
807
|
|
|
808
|
+
// Seed once, then refresh the clip catalog only when a worker reports a job
|
|
809
|
+
// transition (coalesced) — a produced clip appears on the DONE push, not on a
|
|
810
|
+
// 6s timer.
|
|
807
811
|
useEffect(() => {
|
|
808
812
|
mounted.current = true;
|
|
809
813
|
void load(false);
|
|
810
|
-
|
|
814
|
+
let coalesce: number | null = null;
|
|
815
|
+
const unsub = subscribeJobStream(() => {
|
|
816
|
+
if (coalesce != null) return;
|
|
817
|
+
coalesce = window.setTimeout(() => {
|
|
818
|
+
coalesce = null;
|
|
819
|
+
void load(true);
|
|
820
|
+
}, 400);
|
|
821
|
+
});
|
|
811
822
|
return () => {
|
|
812
823
|
mounted.current = false;
|
|
813
|
-
window.
|
|
824
|
+
if (coalesce != null) window.clearTimeout(coalesce);
|
|
825
|
+
unsub();
|
|
814
826
|
};
|
|
815
827
|
}, [load]);
|
|
816
828
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// k94 — the ONE-PATH "Create identity" job watcher.
|
|
2
2
|
//
|
|
3
3
|
// Both create routes (from-video → identity_from_video, from-images →
|
|
4
|
-
// identity_mesh_build) hand back a bus job_id; this hook
|
|
5
|
-
// GET /video/jobs/<id> (`videoJobUrl`)
|
|
4
|
+
// identity_mesh_build) hand back a bus job_id; this hook seeds once from the generic
|
|
5
|
+
// GET /video/jobs/<id> (`videoJobUrl`) and follows pushed job transitions.
|
|
6
6
|
// station do, and reduces the reply to what the panel shows on ONE line:
|
|
7
7
|
// stage · % · log tail
|
|
8
8
|
// `progress` is the relay's mirrored blob ({stage, progress 0..1, log_tail[]}
|
|
@@ -10,16 +10,17 @@
|
|
|
10
10
|
// bus's numeric bar, `result` is the terminal JobResult ({ok, error?,
|
|
11
11
|
// identities?}). Terminal = `result.ok` is a boolean. Bounded (90 min — char360
|
|
12
12
|
// plus a textured Hunyuan3D mesh PER character runs minutes each; clownworld's
|
|
13
|
-
// own ceiling) so a wedged remote never
|
|
13
|
+
// own ceiling) so a wedged remote never holds the panel forever.
|
|
14
14
|
//
|
|
15
15
|
// Why not the session job tracker (video/jobTracker.ts)? Its JobKind/StationId
|
|
16
16
|
// unions + per-kind terminal parse are closed over the crop/frames/generate
|
|
17
17
|
// arms; widening them is a cross-station change outside this task's files. The
|
|
18
|
-
//
|
|
18
|
+
// job shape here is the same one ExtractFromVideoPanel / generateFullIdentity
|
|
19
19
|
// already use in this station.
|
|
20
20
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
21
21
|
import { request, okValue } from "../../transport/client";
|
|
22
22
|
import { videoJobUrl } from "../../config";
|
|
23
|
+
import { subscribeJobStream } from "../../video/jobStream";
|
|
23
24
|
|
|
24
25
|
export type CreateJobPhase = "idle" | "queued" | "running" | "done" | "error";
|
|
25
26
|
|
|
@@ -52,7 +53,6 @@ const IDLE: CreateJobView = {
|
|
|
52
53
|
phase: "idle", jobId: null, stage: null, pct: null, logLine: null, error: null, identities: [],
|
|
53
54
|
};
|
|
54
55
|
|
|
55
|
-
const POLL_MS = 4000;
|
|
56
56
|
const CAP_MS = 90 * 60 * 1000;
|
|
57
57
|
|
|
58
58
|
type JobReply = {
|
|
@@ -79,6 +79,7 @@ export interface CreateJobApi {
|
|
|
79
79
|
export function useIdentityCreateJob(): CreateJobApi {
|
|
80
80
|
const [view, setView] = useState<CreateJobView>(IDLE);
|
|
81
81
|
const timer = useRef<number | null>(null);
|
|
82
|
+
const unsubscribe = useRef<(() => void) | null>(null);
|
|
82
83
|
const active = useRef(false);
|
|
83
84
|
|
|
84
85
|
const stop = useCallback(() => {
|
|
@@ -87,6 +88,8 @@ export function useIdentityCreateJob(): CreateJobApi {
|
|
|
87
88
|
window.clearTimeout(timer.current);
|
|
88
89
|
timer.current = null;
|
|
89
90
|
}
|
|
91
|
+
unsubscribe.current?.();
|
|
92
|
+
unsubscribe.current = null;
|
|
90
93
|
}, []);
|
|
91
94
|
|
|
92
95
|
useEffect(() => stop, [stop]);
|
|
@@ -98,19 +101,14 @@ export function useIdentityCreateJob(): CreateJobApi {
|
|
|
98
101
|
setView({ ...IDLE, phase: "queued", jobId });
|
|
99
102
|
const deadline = Date.now() + CAP_MS;
|
|
100
103
|
|
|
101
|
-
|
|
102
|
-
if (!active.current) return;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const res = await request<unknown>(videoJobUrl(jobId), {
|
|
109
|
-
meta: { specKey: "studio", operation: "identity.create.status" },
|
|
110
|
-
});
|
|
104
|
+
timer.current = window.setTimeout(() => {
|
|
105
|
+
if (!active.current || Date.now() < deadline) return;
|
|
106
|
+
stop();
|
|
107
|
+
setView((v) => ({ ...v, phase: "error", error: "Timed out waiting for the identity build." }));
|
|
108
|
+
}, CAP_MS);
|
|
109
|
+
|
|
110
|
+
const accept = (job: JobReply) => {
|
|
111
111
|
if (!active.current) return;
|
|
112
|
-
if (res.ok) {
|
|
113
|
-
const job = okValue(res) as JobReply;
|
|
114
112
|
const result = job.result;
|
|
115
113
|
if (result && typeof result.ok === "boolean") {
|
|
116
114
|
stop();
|
|
@@ -150,11 +148,18 @@ export function useIdentityCreateJob(): CreateJobApi {
|
|
|
150
148
|
pct: ratio == null ? null : Math.max(0, Math.min(100, Math.round(ratio * 100))),
|
|
151
149
|
logLine: typeof last === "string" ? last : null,
|
|
152
150
|
}));
|
|
153
|
-
}
|
|
154
|
-
// queued / running / transient poll failure → keep polling.
|
|
155
|
-
timer.current = window.setTimeout(() => void pollOnce(), POLL_MS);
|
|
156
151
|
};
|
|
157
|
-
|
|
152
|
+
let pushed = false;
|
|
153
|
+
unsubscribe.current = subscribeJobStream((ev) => {
|
|
154
|
+
if (ev.job_id !== jobId || !ev.job) return;
|
|
155
|
+
pushed = true;
|
|
156
|
+
accept(ev.job as JobReply);
|
|
157
|
+
});
|
|
158
|
+
void request<unknown>(videoJobUrl(jobId), {
|
|
159
|
+
meta: { specKey: "studio", operation: "identity.create.status" },
|
|
160
|
+
}).then((res) => {
|
|
161
|
+
if (res.ok && !pushed) accept(okValue(res) as JobReply);
|
|
162
|
+
});
|
|
158
163
|
},
|
|
159
164
|
[stop],
|
|
160
165
|
);
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from "../../config";
|
|
10
10
|
import { jobRecordSchema, isTerminal } from "../../video/contract";
|
|
11
11
|
import type { JobStatus } from "../../video/contract";
|
|
12
|
+
import { subscribeJobStream } from "../../video/jobStream";
|
|
12
13
|
|
|
13
14
|
export const DEFAULT_VIEWS = ["front", "three_quarter", "profile", "back"] as const;
|
|
14
15
|
export type ViewName = (typeof DEFAULT_VIEWS)[number];
|
|
@@ -69,16 +70,18 @@ export function useReconstruction(slug: string, onComplete: () => void): Reconst
|
|
|
69
70
|
const [error, setError] = useState<string | null>(null);
|
|
70
71
|
|
|
71
72
|
const mounted = useRef(true);
|
|
72
|
-
|
|
73
|
+
// Unsubscribe from the shared job stream while this run's jobs are in flight
|
|
74
|
+
// (replaces the retired 2s poll interval — a transition re-reads on push).
|
|
75
|
+
const unsub = useRef<null | (() => void)>(null);
|
|
73
76
|
const idsRef = useRef<string[]>([]);
|
|
74
77
|
const statusesRef = useRef<Record<string, JobStatus>>({});
|
|
75
78
|
const onCompleteRef = useRef(onComplete);
|
|
76
79
|
onCompleteRef.current = onComplete;
|
|
77
80
|
|
|
78
81
|
const stopPoll = useCallback(() => {
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
if (unsub.current != null) {
|
|
83
|
+
unsub.current();
|
|
84
|
+
unsub.current = null;
|
|
82
85
|
}
|
|
83
86
|
}, []);
|
|
84
87
|
|
|
@@ -156,7 +159,10 @@ export function useReconstruction(slug: string, onComplete: () => void): Reconst
|
|
|
156
159
|
statusesRef.current = seeded;
|
|
157
160
|
setStatuses(seeded);
|
|
158
161
|
void poll();
|
|
159
|
-
|
|
162
|
+
// Re-read this run's job ids whenever a worker reports ANY transition. The
|
|
163
|
+
// set is tiny and poll() is idempotent, so no per-id event filtering is needed.
|
|
164
|
+
if (unsub.current != null) unsub.current();
|
|
165
|
+
unsub.current = subscribeJobStream(() => void poll());
|
|
160
166
|
}, [poll]);
|
|
161
167
|
|
|
162
168
|
const generate = useCallback(
|
|
@@ -71,14 +71,13 @@ export function PromptAssistButtons({
|
|
|
71
71
|
>
|
|
72
72
|
<option value="">fleet default</option>
|
|
73
73
|
{models.map((m) => (
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
// `cold` = central only (a download THEN a load). "cold" is reserved for
|
|
77
|
-
// not-on-hot-drive, so it never mislabels a hot or loaded model.
|
|
74
|
+
// Only call a model "cold" when the server explicitly reports cold.
|
|
75
|
+
// Older responses lack state; use serving/held before saying unknown.
|
|
78
76
|
<option key={m.model} value={m.model}>
|
|
79
77
|
{m.model}
|
|
80
|
-
{m.state === "serving" || m.state === "loaded" ? ""
|
|
81
|
-
: m.state === "hot" ? " ·
|
|
78
|
+
{m.state === "serving" || m.state === "loaded" || m.serving ? " · ready"
|
|
79
|
+
: m.state === "hot" || m.held ? " · available (loads)"
|
|
80
|
+
: m.state === "cold" ? " · cold (downloads)" : " · availability unknown"}
|
|
82
81
|
</option>
|
|
83
82
|
))}
|
|
84
83
|
</select>
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// SHARED job-event stream — the client half of design 1 (2026-10-05). The /video
|
|
2
|
+
// page no longer polls GET /video/jobs on a timer. Every list/tracker surface
|
|
3
|
+
// SEEDS ONCE from its own GET, then subscribes here; this module holds ONE
|
|
4
|
+
// EventSource on /api/video/jobs/stream for the whole page (ref-counted: opens on
|
|
5
|
+
// the first subscriber, closes on the last) and hands each pushed row to every
|
|
6
|
+
// subscriber. A row is pushed the moment a worker reports a transition — never on
|
|
7
|
+
// a timer.
|
|
8
|
+
//
|
|
9
|
+
// Structural twin of video/useStudioAssistLog.ts (the same module-scope store +
|
|
10
|
+
// reference-counted connection), with ONE deliberate difference: it is
|
|
11
|
+
// 404-TOLERANT and NEVER polls. If the stream endpoint is absent (first connect
|
|
12
|
+
// errors before it ever opened), we do NOT reconnect — each consumer already did
|
|
13
|
+
// its one-time seed, so the page simply stays on that seed. We reconnect ONLY
|
|
14
|
+
// after a stream that HAD opened drops (the server caps a stream at an hour and
|
|
15
|
+
// expects a re-attach); that is at most ~1/hour, not a poll.
|
|
16
|
+
import { hugpyConfig } from "../config";
|
|
17
|
+
import { videoShareKey } from "../share";
|
|
18
|
+
|
|
19
|
+
/** One pushed job row (jobstream.py). `job` is the GET /video/jobs/<id> view, or
|
|
20
|
+
* null when the job is gone; `stage:"stream.ready"` marks the transport hello. */
|
|
21
|
+
export interface JobEvent {
|
|
22
|
+
job_id?: string;
|
|
23
|
+
status?: string | null;
|
|
24
|
+
owner?: string | null;
|
|
25
|
+
private?: boolean;
|
|
26
|
+
job?: unknown;
|
|
27
|
+
ts?: number;
|
|
28
|
+
stage?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Listener = (ev: JobEvent) => void;
|
|
32
|
+
|
|
33
|
+
const listeners = new Set<Listener>();
|
|
34
|
+
let es: EventSource | null = null;
|
|
35
|
+
let refs = 0;
|
|
36
|
+
let everOpened = false;
|
|
37
|
+
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
38
|
+
const RETRY_MS = 5000;
|
|
39
|
+
|
|
40
|
+
function streamUrl(): string {
|
|
41
|
+
const base = hugpyConfig.jobsStreamUrl;
|
|
42
|
+
const key = videoShareKey();
|
|
43
|
+
if (!key) return base;
|
|
44
|
+
const sep = base.includes("?") ? "&" : "?";
|
|
45
|
+
return `${base}${sep}share=${encodeURIComponent(key)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function openStream(): void {
|
|
49
|
+
if (es || typeof window === "undefined" || typeof EventSource === "undefined") return;
|
|
50
|
+
let source: EventSource;
|
|
51
|
+
try {
|
|
52
|
+
source = new EventSource(streamUrl(), { withCredentials: true });
|
|
53
|
+
} catch {
|
|
54
|
+
return; // EventSource unavailable — consumers keep their one-time seed
|
|
55
|
+
}
|
|
56
|
+
es = source;
|
|
57
|
+
source.onopen = () => {
|
|
58
|
+
everOpened = true;
|
|
59
|
+
};
|
|
60
|
+
source.onmessage = (e: MessageEvent) => {
|
|
61
|
+
let ev: JobEvent;
|
|
62
|
+
try {
|
|
63
|
+
ev = JSON.parse(e.data);
|
|
64
|
+
} catch {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Let consumers re-read their current rows after every attach. A transition
|
|
68
|
+
// can happen between their initial GET and the stream becoming ready.
|
|
69
|
+
for (const fn of listeners) {
|
|
70
|
+
try {
|
|
71
|
+
fn(ev);
|
|
72
|
+
} catch {
|
|
73
|
+
/* one bad subscriber must not stall the rest */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
source.onerror = () => {
|
|
78
|
+
source.close();
|
|
79
|
+
if (es === source) es = null;
|
|
80
|
+
// RECONNECT only if this stream had actually opened before dropping (the
|
|
81
|
+
// server's hourly re-attach). A first connect that errors before opening =
|
|
82
|
+
// the endpoint is absent/unsupported → do NOT retry, do NOT poll; the page
|
|
83
|
+
// stays on each consumer's one-time seed.
|
|
84
|
+
if (refs > 0 && everOpened) {
|
|
85
|
+
everOpened = false;
|
|
86
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
87
|
+
retryTimer = setTimeout(() => {
|
|
88
|
+
if (refs > 0) openStream();
|
|
89
|
+
}, RETRY_MS);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function closeStream(): void {
|
|
95
|
+
if (retryTimer) {
|
|
96
|
+
clearTimeout(retryTimer);
|
|
97
|
+
retryTimer = null;
|
|
98
|
+
}
|
|
99
|
+
if (es) {
|
|
100
|
+
es.close();
|
|
101
|
+
es = null;
|
|
102
|
+
}
|
|
103
|
+
everOpened = false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Subscribe to pushed job rows. Returns an unsubscribe. The underlying
|
|
108
|
+
* EventSource is opened on the first subscriber and closed on the last.
|
|
109
|
+
*/
|
|
110
|
+
export function subscribeJobStream(fn: Listener): () => void {
|
|
111
|
+
listeners.add(fn);
|
|
112
|
+
refs += 1;
|
|
113
|
+
if (refs === 1) openStream();
|
|
114
|
+
return () => {
|
|
115
|
+
listeners.delete(fn);
|
|
116
|
+
refs = Math.max(0, refs - 1);
|
|
117
|
+
if (refs === 0) closeStream();
|
|
118
|
+
};
|
|
119
|
+
}
|
package/src/video/jobTracker.ts
CHANGED
|
@@ -25,9 +25,10 @@ import { useSyncExternalStore } from "react";
|
|
|
25
25
|
import { getSessionId } from "../session";
|
|
26
26
|
import { isCanned } from "../demo/mode";
|
|
27
27
|
import { installDemoTransport } from "../demo/demoFetch";
|
|
28
|
-
import {
|
|
28
|
+
import { jobCancelUrl, videoJobUrl } from "../config";
|
|
29
29
|
import { request, okValue } from "../transport/client";
|
|
30
30
|
import { addToLibrary } from "./mediaLibrary";
|
|
31
|
+
import { subscribeJobStream, type JobEvent } from "./jobStream";
|
|
31
32
|
import {
|
|
32
33
|
jobRecordSchema,
|
|
33
34
|
isTerminal,
|
|
@@ -38,12 +39,6 @@ import {
|
|
|
38
39
|
type AnyProgress,
|
|
39
40
|
} from "./contract";
|
|
40
41
|
import type { SpatialRegion, TemporalRegion } from "../regions/types";
|
|
41
|
-
import {
|
|
42
|
-
GENERATE_POLL_CAP_MS,
|
|
43
|
-
JOB_POLL_CAP_MS,
|
|
44
|
-
generateScenePollCapMs,
|
|
45
|
-
generateMoviePollCapMs,
|
|
46
|
-
} from "../stations/pollCaps";
|
|
47
42
|
|
|
48
43
|
/** The job kinds this arm enqueues, one poll-parse branch per kind. */
|
|
49
44
|
export type JobKind =
|
|
@@ -52,7 +47,8 @@ export type JobKind =
|
|
|
52
47
|
| "audio_extract"
|
|
53
48
|
| "generate_image"
|
|
54
49
|
| "generate_scene"
|
|
55
|
-
| "generate_movie"
|
|
50
|
+
| "generate_movie"
|
|
51
|
+
| "studio_i2v";
|
|
56
52
|
|
|
57
53
|
/** The station a job belongs to — also the library origin for crop pushes.
|
|
58
54
|
* "video-crop" (Studio slice 2b) is the dedicated id for the one-pass spatial+
|
|
@@ -69,7 +65,8 @@ export type StationId =
|
|
|
69
65
|
| "frames"
|
|
70
66
|
| "generate"
|
|
71
67
|
| "generate-tester"
|
|
72
|
-
| "generate-scene-parts"
|
|
68
|
+
| "generate-scene-parts"
|
|
69
|
+
| "studio";
|
|
73
70
|
|
|
74
71
|
/**
|
|
75
72
|
* One tracked job. Persisted to sessionStorage, so — because a record may be
|
|
@@ -172,9 +169,6 @@ export interface TrackedJob {
|
|
|
172
169
|
/** The single sessionStorage key prefix (build marker — must reach the bundle). */
|
|
173
170
|
const KEY_PREFIX = "vi.jobTracker.v1";
|
|
174
171
|
|
|
175
|
-
/** Module poll cadence — matches the retired per-hook POLL_MS exactly. */
|
|
176
|
-
const POLL_MS = 800;
|
|
177
|
-
|
|
178
172
|
// Session-scoped key so two tabs keep independent trackers, and the persisted
|
|
179
173
|
// list travels with the same session id the uploads/jobs are tagged by.
|
|
180
174
|
//
|
|
@@ -199,7 +193,9 @@ function storageKey(): string {
|
|
|
199
193
|
let cache: TrackedJob[] | null = null;
|
|
200
194
|
const listeners = new Set<() => void>();
|
|
201
195
|
let storageBound = false;
|
|
202
|
-
|
|
196
|
+
// The shared job-stream unsubscribe while ≥1 tracked job is active (null = not
|
|
197
|
+
// subscribed). The tracker no longer polls; it applies pushed rows by job_id.
|
|
198
|
+
let streamUnsub: (() => void) | null = null;
|
|
203
199
|
|
|
204
200
|
function readStorage(): TrackedJob[] {
|
|
205
201
|
try {
|
|
@@ -253,36 +249,6 @@ function anyActive(): boolean {
|
|
|
253
249
|
return ensureLoaded().some(isJobActive);
|
|
254
250
|
}
|
|
255
251
|
|
|
256
|
-
function capFor(
|
|
257
|
-
kind: JobKind,
|
|
258
|
-
nFrames?: number | null,
|
|
259
|
-
chain?: boolean | null,
|
|
260
|
-
segments?: number | null,
|
|
261
|
-
): number {
|
|
262
|
-
if (kind === "generate_movie")
|
|
263
|
-
return generateMoviePollCapMs(segments ?? 1, nFrames ?? undefined, chain ?? false);
|
|
264
|
-
if (kind === "generate_scene")
|
|
265
|
-
return generateScenePollCapMs(nFrames ?? 1, chain ?? false);
|
|
266
|
-
return kind === "generate_image" ? GENERATE_POLL_CAP_MS : JOB_POLL_CAP_MS;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
function opFor(kind: JobKind): string {
|
|
270
|
-
switch (kind) {
|
|
271
|
-
case "crop":
|
|
272
|
-
return "crop.poll";
|
|
273
|
-
case "frame_extract":
|
|
274
|
-
return "frame_extract.poll";
|
|
275
|
-
case "audio_extract":
|
|
276
|
-
return "audio_extract.poll";
|
|
277
|
-
case "generate_image":
|
|
278
|
-
return "generate_image.poll";
|
|
279
|
-
case "generate_scene":
|
|
280
|
-
return "generate_scene.poll";
|
|
281
|
-
case "generate_movie":
|
|
282
|
-
return "generate_movie.poll";
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
|
|
286
252
|
function failMsgFor(kind: JobKind): string {
|
|
287
253
|
switch (kind) {
|
|
288
254
|
case "crop":
|
|
@@ -297,6 +263,8 @@ function failMsgFor(kind: JobKind): string {
|
|
|
297
263
|
return "Scene generation failed.";
|
|
298
264
|
case "generate_movie":
|
|
299
265
|
return "Movie generation failed.";
|
|
266
|
+
case "studio_i2v":
|
|
267
|
+
return "Clip generation failed.";
|
|
300
268
|
}
|
|
301
269
|
}
|
|
302
270
|
|
|
@@ -383,6 +351,11 @@ function pushOutputs(rec: TrackedJob, outputs: MediaRef[]): void {
|
|
|
383
351
|
});
|
|
384
352
|
return;
|
|
385
353
|
}
|
|
354
|
+
if (rec.kind === "studio_i2v") {
|
|
355
|
+
const out = outputs.find((m) => m.kind === "video");
|
|
356
|
+
if (out) addToLibrary(out, "studio", rec.libLabel ?? "studio clip");
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
386
359
|
// audio_extract: intermediate (feeds the temporal editor) — never pushed.
|
|
387
360
|
}
|
|
388
361
|
|
|
@@ -432,51 +405,57 @@ function applyPoll(rec: TrackedJob, raw: unknown): void {
|
|
|
432
405
|
}
|
|
433
406
|
}
|
|
434
407
|
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
408
|
+
// Apply one pushed row to its tracked record. The stream event carries `job` —
|
|
409
|
+
// the GET /video/jobs/<id> view applyPoll already parses — so a transition is
|
|
410
|
+
// applied the moment a worker reports it, with no poll. Events for untracked or
|
|
411
|
+
// already-terminal ids are ignored; a burst self-coalesces (each is cheap).
|
|
412
|
+
function onStreamEvent(ev: JobEvent): void {
|
|
413
|
+
if (ev.stage === "stream.ready") {
|
|
414
|
+
for (const rec of ensureLoaded().filter((row) => !isJobTerminal(row))) {
|
|
415
|
+
void request<unknown>(videoJobUrl(rec.jobId), {
|
|
416
|
+
meta: { specKey: rec.station, operation: "video.job.reconcile" },
|
|
417
|
+
}).then((res) => {
|
|
418
|
+
const current = ensureLoaded().find((row) => row.jobId === rec.jobId);
|
|
419
|
+
if (res.ok && current && !isJobTerminal(current))
|
|
420
|
+
applyPoll(current, okValue(res));
|
|
421
|
+
disarmIfIdle();
|
|
422
|
+
});
|
|
447
423
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const jobId = ev.job_id;
|
|
427
|
+
if (!jobId) return;
|
|
428
|
+
const rec = ensureLoaded().find((r) => r.jobId === jobId);
|
|
429
|
+
if (!rec || !isJobActive(rec)) return;
|
|
430
|
+
if (ev.job == null) {
|
|
431
|
+
// The server no longer holds this job (gone) — mark expired, stop watching it.
|
|
432
|
+
mutate(rec.jobId, { expired: true });
|
|
433
|
+
} else {
|
|
434
|
+
applyPoll(rec, ev.job);
|
|
453
435
|
}
|
|
454
436
|
disarmIfIdle();
|
|
455
437
|
}
|
|
456
438
|
|
|
457
|
-
//
|
|
458
|
-
// nothing is active.
|
|
439
|
+
// Subscribe to the shared job stream while ≥1 record is active; a no-op if
|
|
440
|
+
// already subscribed or nothing is active. Replaces the retired poll interval —
|
|
441
|
+
// there is no timer anywhere now; rows arrive on push.
|
|
459
442
|
function armPolling(): void {
|
|
460
443
|
if (typeof window === "undefined") return;
|
|
461
|
-
if (
|
|
444
|
+
if (streamUnsub != null) return;
|
|
462
445
|
if (!anyActive()) return;
|
|
463
|
-
// Ordering guard for the canned demo:
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
// brochure must never do that). installDemoTransport is idempotent, so calling it
|
|
468
|
-
// here guarantees the shim is installed before ANY canned poll fires, whatever the
|
|
469
|
-
// module import order. No-op outside canned mode.
|
|
446
|
+
// Ordering guard for the canned demo: initTracker() runs at IMPORT time (before
|
|
447
|
+
// entry.tsx's installVideoDemo()); installDemoTransport is idempotent and also
|
|
448
|
+
// shims EventSource, so calling it here guarantees the canned stream shim is up
|
|
449
|
+
// before we subscribe, whatever the module import order. No-op outside canned mode.
|
|
470
450
|
if (isCanned()) installDemoTransport();
|
|
471
|
-
|
|
472
|
-
void pollTick();
|
|
451
|
+
streamUnsub = subscribeJobStream(onStreamEvent);
|
|
473
452
|
}
|
|
474
453
|
|
|
475
|
-
//
|
|
454
|
+
// Drop the subscription once no record is active (reopened by armPolling later).
|
|
476
455
|
function disarmIfIdle(): void {
|
|
477
|
-
if (
|
|
478
|
-
|
|
479
|
-
|
|
456
|
+
if (streamUnsub != null && !anyActive()) {
|
|
457
|
+
streamUnsub();
|
|
458
|
+
streamUnsub = null;
|
|
480
459
|
}
|
|
481
460
|
}
|
|
482
461
|
|
|
@@ -547,6 +526,13 @@ export function trackJob(input: TrackJobInput): void {
|
|
|
547
526
|
writeStorage(cache);
|
|
548
527
|
emit();
|
|
549
528
|
armPolling();
|
|
529
|
+
void request<unknown>(videoJobUrl(input.jobId), {
|
|
530
|
+
meta: { specKey: "studio", operation: "video.job.seed" },
|
|
531
|
+
}).then((res) => {
|
|
532
|
+
const active = ensureLoaded().find((row) => row.jobId === input.jobId);
|
|
533
|
+
if (res.ok && active && isJobActive(active)) applyPoll(active, okValue(res));
|
|
534
|
+
disarmIfIdle();
|
|
535
|
+
});
|
|
550
536
|
}
|
|
551
537
|
|
|
552
538
|
/**
|
|
@@ -645,6 +631,10 @@ function bindStorageOnce(): void {
|
|
|
645
631
|
cache = readStorage();
|
|
646
632
|
emit();
|
|
647
633
|
armPolling();
|
|
634
|
+
onStreamEvent({ stage: "stream.ready" });
|
|
635
|
+
});
|
|
636
|
+
window.addEventListener("focus", () => {
|
|
637
|
+
onStreamEvent({ stage: "stream.ready" });
|
|
648
638
|
});
|
|
649
639
|
}
|
|
650
640
|
|
|
@@ -697,6 +687,7 @@ function initTracker(): void {
|
|
|
697
687
|
writeStorage(cache);
|
|
698
688
|
}
|
|
699
689
|
armPolling();
|
|
690
|
+
onStreamEvent({ stage: "stream.ready" });
|
|
700
691
|
}
|
|
701
692
|
|
|
702
693
|
initTracker();
|