@hugpy/video-intelligence-ui 0.3.2 → 0.3.4
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 +0 -0
- package/README.md +9 -0
- package/dist/assets/hugpy-mark-BXktFcOQ.png +0 -0
- package/dist/assets/index-DMoT8G1q.css +1 -0
- package/dist/assets/index-GeD1mo16.js +4190 -0
- package/dist/index.html +71 -0
- package/entry.tsx +6 -27
- package/index.html +4 -4
- package/package.json +44 -5
- package/src/assets/hugpy-mark.png +0 -0
- package/src/config.ts +11 -27
- package/src/demo/DemoBanner.tsx +0 -0
- package/src/demo/demo.css +0 -0
- package/src/demo/demoFetch.ts +0 -9
- package/src/demo/fixtures.ts +0 -0
- package/src/demo/identityFixture.ts +0 -0
- package/src/demo/index.ts +0 -0
- package/src/demo/mediaBase.ts +0 -0
- package/src/demo/mode.ts +0 -0
- package/src/demo/promptAssist.ts +0 -0
- package/src/demo/sampleData.ts +0 -0
- package/src/demo/sampleSets.ts +0 -0
- package/src/demo/seed.ts +0 -0
- package/src/nav/BrandMark.css +0 -0
- package/src/nav/BrandMark.tsx +0 -0
- package/src/nav/Navbar.css +0 -0
- package/src/nav/Navbar.tsx +8 -3
- package/src/regions/SpatialRegionEditor.tsx +0 -0
- package/src/regions/TemporalRegionEditor.tsx +0 -0
- package/src/regions/types.ts +0 -0
- package/src/session.ts +8 -43
- package/src/share.ts +0 -0
- package/src/stations/ActivePanel.tsx +0 -0
- package/src/stations/ActiveProcessesStation.tsx +0 -0
- package/src/stations/AddFileBar.tsx +0 -0
- package/src/stations/AudioCropStation.tsx +0 -0
- package/src/stations/ComfyPanel.tsx +0 -0
- package/src/stations/ComfyStation.tsx +0 -0
- package/src/stations/CondensedKnobStrip.tsx +0 -0
- package/src/stations/ConsolePanel.tsx +0 -0
- package/src/stations/FrameExtractStation.tsx +0 -0
- package/src/stations/GenIdentityBar.tsx +0 -0
- package/src/stations/GenerateStation.tsx +10 -6
- package/src/stations/GoalComposer.tsx +0 -0
- package/src/stations/ImageCropStation.tsx +0 -0
- package/src/stations/KnobInfo.tsx +0 -0
- package/src/stations/PlaceholderStation.tsx +0 -0
- package/src/stations/ScriptFirstStation.tsx +4 -0
- package/src/stations/SectionTabs.tsx +0 -0
- package/src/stations/SharePanel.tsx +4 -4
- package/src/stations/SidebarRegistry.tsx +0 -0
- package/src/stations/StationShell.tsx +0 -0
- package/src/stations/StudioClipsStation.tsx +0 -0
- package/src/stations/TemplateStepsPanel.tsx +190 -0
- package/src/stations/ToksPanel.tsx +1 -2
- package/src/stations/WorkbenchDrawer.tsx +0 -0
- package/src/stations/WorkbenchSidebar.tsx +8 -261
- package/src/stations/WorkbenchStation.tsx +0 -0
- package/src/stations/comfyEmbed.ts +0 -0
- package/src/stations/drawerShell.tsx +0 -0
- package/src/stations/ops/AudioCropCore.tsx +0 -0
- package/src/stations/ops/CharacterGroupsPanel.tsx +75 -83
- package/src/stations/ops/FrameExtractCore.tsx +0 -0
- package/src/stations/ops/ImageCropCore.tsx +0 -0
- package/src/stations/ops/VideoCropCore.tsx +0 -0
- package/src/stations/ops/useVideoCropJobs.ts +0 -0
- package/src/stations/oracle/DagPanel.tsx +0 -0
- package/src/stations/oracle/OracleStation.tsx +2 -0
- package/src/stations/oracle/PerformanceComposer.tsx +119 -0
- package/src/stations/oracle/SpatialTab.tsx +0 -0
- package/src/stations/oracle/StewardPanel.tsx +0 -0
- package/src/stations/oracle/oracle.css +33 -0
- package/src/stations/oracle/oracleShared.tsx +0 -0
- package/src/stations/oracle/oracleTypes.ts +0 -0
- package/src/stations/oracle/useOracle.ts +0 -0
- package/src/stations/pollCaps.ts +0 -0
- package/src/stations/registry.ts +0 -0
- package/src/stations/scriptFirst.css +23 -0
- package/src/stations/studio/AngleRingEditor.tsx +0 -0
- package/src/stations/studio/IdentitiesStation.tsx +86 -76
- package/src/stations/studio/IdentityCreatePanel.tsx +0 -0
- package/src/stations/studio/IdentityProfileControls.tsx +0 -0
- package/src/stations/studio/StudioActiveProcesses.tsx +0 -0
- package/src/stations/studio/StudioAssistLogPanel.tsx +0 -0
- package/src/stations/studio/StudioGenerateMode.tsx +3 -2
- package/src/stations/studio/StudioGenerateTab.tsx +278 -190
- package/src/stations/studio/StudioItemCard.tsx +0 -0
- package/src/stations/studio/StudioLibraryTab.tsx +0 -0
- package/src/stations/studio/StudioMovieComposer.tsx +321 -46
- package/src/stations/studio/StudioMovieSessions.tsx +12 -19
- package/src/stations/studio/StudioPlane.tsx +28 -5
- package/src/stations/studio/StudioSplicedRow.tsx +0 -0
- package/src/stations/studio/StudioViewer.tsx +6 -11
- package/src/stations/studio/TurntableViewer.tsx +0 -0
- package/src/stations/studio/coordinationBadge.tsx +0 -0
- package/src/stations/studio/identityUpload.ts +0 -0
- package/src/stations/studio/movieTimeline.ts +0 -0
- package/src/stations/studio/promptCard.tsx +0 -0
- package/src/stations/studio/studioItem.ts +0 -0
- package/src/stations/studio/studioShared.tsx +14 -2
- package/src/stations/studio/useIdentityCreateJob.ts +26 -21
- package/src/stations/studio/useIdentityProfiles.ts +0 -0
- package/src/stations/studio/useReconstruction.ts +11 -5
- package/src/stations/types.ts +0 -0
- package/src/stations/useAudioCropJobs.ts +0 -0
- package/src/stations/useAudioExtractJob.ts +0 -0
- package/src/stations/useCropJobs.ts +0 -0
- package/src/stations/useFrameJobs.ts +0 -0
- package/src/stations/useGenerateJob.ts +0 -0
- package/src/stations/useGenerateMovieJob.ts +0 -0
- package/src/stations/useGenerateRowJobs.ts +0 -0
- package/src/stations/useGenerateSceneJob.ts +0 -0
- package/src/stations/useGenerateSceneRowJobs.ts +0 -0
- package/src/stations/useScriptFirstRun.ts +0 -0
- package/src/style/app.css +49 -0
- package/src/transport/client.ts +0 -0
- package/src/transport/telemetry.ts +0 -0
- package/src/video/DropReceptacle.tsx +0 -0
- package/src/video/LengthRow.tsx +0 -0
- package/src/video/LibraryGroups.tsx +0 -0
- package/src/video/PromptAssistButtons.tsx +5 -4
- package/src/video/UnnamedWarn.tsx +0 -0
- package/src/video/composerBridge.ts +0 -0
- package/src/video/contract.ts +0 -0
- package/src/video/coordinationReport.ts +0 -0
- package/src/video/jobClock.ts +0 -0
- package/src/video/jobStream.ts +119 -0
- package/src/video/jobTracker.ts +67 -76
- package/src/video/mediaLibrary.ts +0 -0
- package/src/video/modelsFeed.ts +105 -0
- package/src/video/renderCompat.ts +0 -0
- package/src/video/sessionForm.ts +0 -0
- package/src/video/spreadAssist.ts +0 -0
- package/src/video/studioBridge.ts +0 -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/useMoviePresets.ts +0 -0
- package/src/video/useMovieSessions.ts +15 -6
- package/src/video/useOperatorHistory.ts +11 -101
- package/src/video/usePresets.ts +0 -0
- package/src/video/useProjects.ts +0 -0
- package/src/video/usePromptAssist.ts +70 -25
- package/src/video/useRenderPresets.ts +0 -0
- package/src/video/useStudioAssistLog.ts +0 -0
- package/src/video/useStudioPresets.ts +0 -0
- package/src/video/useToks.ts +14 -6
- package/src/video/watchVideoJob.ts +43 -0
- package/tsconfig.json +0 -0
- package/vite.config.ts +0 -0
- package/src/stations/studio/StudioMovieComposer.tsx.bak-declutter-20260813 +0 -3031
- package/src/video/useWorkerCalls.ts +0 -165
|
@@ -326,7 +326,7 @@ function SessionRowBody({
|
|
|
326
326
|
|
|
327
327
|
/** The panel body — split out so the poll it drives exists ONLY while the panel is
|
|
328
328
|
* open (mounting is the visibility signal; see the `active` gate below). */
|
|
329
|
-
function SessionsBody() {
|
|
329
|
+
export function SessionsBody() {
|
|
330
330
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
331
331
|
// A hidden browser tab is not a visible panel: pause the walk of the movies root
|
|
332
332
|
// while the console is in the background, and reload the moment it comes back.
|
|
@@ -419,7 +419,7 @@ const SESSION_CATEGORIES: ReadonlyArray<{
|
|
|
419
419
|
id: "cinema",
|
|
420
420
|
label: "Cinema",
|
|
421
421
|
kinds: ["generate_studio_movie"],
|
|
422
|
-
hint: "movies
|
|
422
|
+
hint: "cinema movies",
|
|
423
423
|
},
|
|
424
424
|
];
|
|
425
425
|
|
|
@@ -450,7 +450,6 @@ function CategoryJobsBody({
|
|
|
450
450
|
<p className="vi-knob-hint">
|
|
451
451
|
No {label.toLowerCase()} builds in the feed — live ones appear here with
|
|
452
452
|
progress and Cancel; tick “Show finished” above for recent history.
|
|
453
|
-
(Durable resumable sessions exist for Cinema today.)
|
|
454
453
|
</p>
|
|
455
454
|
);
|
|
456
455
|
}
|
|
@@ -491,7 +490,7 @@ export function SessionsPanel({
|
|
|
491
490
|
cancelBusy: Set<string>;
|
|
492
491
|
onCancel: (id: string) => void;
|
|
493
492
|
}) {
|
|
494
|
-
const [open, setOpen] = useState<Set<string>>(() => new Set(
|
|
493
|
+
const [open, setOpen] = useState<Set<string>>(() => new Set());
|
|
495
494
|
const toggle = (id: string) =>
|
|
496
495
|
setOpen((prev) => {
|
|
497
496
|
const next = new Set(prev);
|
|
@@ -504,7 +503,7 @@ export function SessionsPanel({
|
|
|
504
503
|
<p className="vi-knob-headline" style={{ margin: "0.8rem 0 0.2rem" }}>
|
|
505
504
|
Sessions{" "}
|
|
506
505
|
<span className="vi-knob-headline-sub">
|
|
507
|
-
previous & in-flight work, per surface
|
|
506
|
+
previous & in-flight work, per surface
|
|
508
507
|
</span>
|
|
509
508
|
</p>
|
|
510
509
|
{SESSION_CATEGORIES.map((cat) => {
|
|
@@ -526,24 +525,18 @@ export function SessionsPanel({
|
|
|
526
525
|
{cat.label}{" "}
|
|
527
526
|
<span className="vi-knob-headline-sub">
|
|
528
527
|
{cat.hint}
|
|
529
|
-
{
|
|
530
|
-
? ` · ${catJobs.length} in feed`
|
|
531
|
-
: ""}
|
|
528
|
+
{catJobs.length > 0 ? ` · ${catJobs.length} in feed` : ""}
|
|
532
529
|
</span>
|
|
533
530
|
</span>
|
|
534
531
|
</button>
|
|
535
532
|
{isOpen ? (
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
onCancel={onCancel}
|
|
544
|
-
label={cat.label}
|
|
545
|
-
/>
|
|
546
|
-
)
|
|
533
|
+
<CategoryJobsBody
|
|
534
|
+
jobs={catJobs}
|
|
535
|
+
nowMs={nowMs}
|
|
536
|
+
cancelBusy={cancelBusy}
|
|
537
|
+
onCancel={onCancel}
|
|
538
|
+
label={cat.label}
|
|
539
|
+
/>
|
|
547
540
|
) : null}
|
|
548
541
|
</div>
|
|
549
542
|
);
|
|
@@ -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();
|
|
@@ -111,6 +112,9 @@ export function StudioPlane({ registerBridge = false, lockedSurfaceMode }: Studi
|
|
|
111
112
|
const [pinned, setPinned] = useState(false);
|
|
112
113
|
|
|
113
114
|
const topRef = useRef<HTMLDivElement | null>(null);
|
|
115
|
+
const monitorRef = useRef<HTMLDivElement | null>(null);
|
|
116
|
+
const binRef = useRef<HTMLDivElement | null>(null);
|
|
117
|
+
const renderRef = useRef<HTMLDivElement | null>(null);
|
|
114
118
|
|
|
115
119
|
// Round 9: fold into the ONE shell sidebar — reveal its guarded Settings tab (knobs +
|
|
116
120
|
// template dropdown portal there). registerSettings is stable, so this runs once per
|
|
@@ -275,10 +279,24 @@ export function StudioPlane({ registerBridge = false, lockedSurfaceMode }: Studi
|
|
|
275
279
|
|
|
276
280
|
return (
|
|
277
281
|
<div className="vi-studio-workbench" ref={topRef}>
|
|
282
|
+
<nav className="vi-studio-workflow" aria-label="Studio workflow">
|
|
283
|
+
<button type="button" onClick={() => monitorRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })}>
|
|
284
|
+
<span className="vi-studio-workflow-step">01 · Monitor</span>
|
|
285
|
+
<span>Review the current take</span>
|
|
286
|
+
</button>
|
|
287
|
+
<button type="button" onClick={() => binRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })}>
|
|
288
|
+
<span className="vi-studio-workflow-step">02 · Shot bin</span>
|
|
289
|
+
<span>{items.length} staged {items.length === 1 ? "item" : "items"}</span>
|
|
290
|
+
</button>
|
|
291
|
+
<button type="button" onClick={() => renderRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })}>
|
|
292
|
+
<span className="vi-studio-workflow-step">03 · Render</span>
|
|
293
|
+
<span>Clip or cinema</span>
|
|
294
|
+
</button>
|
|
295
|
+
</nav>
|
|
278
296
|
{/* CENTER, movie skeleton: viewer on top → generator below. Viewer header controls
|
|
279
297
|
(Play from library / Clear / follow-newest toggle) are wired here so both
|
|
280
298
|
plane mounts get them for free — see the `pinned` note above. */}
|
|
281
|
-
<StudioViewer
|
|
299
|
+
<div ref={monitorRef} className="vi-studio-workflow-target"><StudioViewer
|
|
282
300
|
clips={clipsState.clips}
|
|
283
301
|
library={libraryMedia}
|
|
284
302
|
selected={selected}
|
|
@@ -286,7 +304,7 @@ export function StudioPlane({ registerBridge = false, lockedSurfaceMode }: Studi
|
|
|
286
304
|
onPick={pickViewerClip}
|
|
287
305
|
onClear={clearViewer}
|
|
288
306
|
onToggleFollow={toggleFollowNewest}
|
|
289
|
-
|
|
307
|
+
/></div>
|
|
290
308
|
|
|
291
309
|
{/* Slice 2b: the staged studio items as a strip of first-class cards with inline
|
|
292
310
|
per-item ops. Additive — it sits ABOVE the generate surface and does not touch
|
|
@@ -294,7 +312,8 @@ export function StudioPlane({ registerBridge = false, lockedSurfaceMode }: Studi
|
|
|
294
312
|
mounts identically (this is the one shared StudioPlane). Each card is wrapped in
|
|
295
313
|
a keyed intrinsic <div> so the map key never lands on a component (which the
|
|
296
314
|
bare-tsc env flags as a spurious prop). */}
|
|
297
|
-
{
|
|
315
|
+
<div ref={binRef} className="vi-studio-workflow-target" aria-label="Shot bin">
|
|
316
|
+
{items.length > 0 ? (
|
|
298
317
|
<div className="vi-studio-item-strip" aria-label="Studio items">
|
|
299
318
|
{items.map((it) => (
|
|
300
319
|
<div className="vi-studio-item-cell" key={it.key}>
|
|
@@ -308,8 +327,10 @@ export function StudioPlane({ registerBridge = false, lockedSurfaceMode }: Studi
|
|
|
308
327
|
</div>
|
|
309
328
|
))}
|
|
310
329
|
</div>
|
|
311
|
-
)}
|
|
330
|
+
) : <p className="vi-studio-bin-empty">Staged media will appear here. Send a library item to Studio or add a source in the render desk.</p>}
|
|
331
|
+
</div>
|
|
312
332
|
|
|
333
|
+
<div ref={renderRef} className="vi-studio-workflow-target">
|
|
313
334
|
<StudioGenerateSurface
|
|
314
335
|
presets={presets}
|
|
315
336
|
presetsLoading={presetsLoading}
|
|
@@ -321,7 +342,9 @@ export function StudioPlane({ registerBridge = false, lockedSurfaceMode }: Studi
|
|
|
321
342
|
onEnqueued={clipsState.refresh}
|
|
322
343
|
settingsHost={reg.settingsHost}
|
|
323
344
|
lockedSurfaceMode={lockedSurfaceMode}
|
|
345
|
+
onSelectSurfaceMode={onSelectSurfaceMode}
|
|
324
346
|
/>
|
|
347
|
+
</div>
|
|
325
348
|
{/* STUDIO-ASSIST LIVE LOG → portaled into the sidebar's ACTIVE (Active
|
|
326
349
|
Processes) tab via the registry's activeExtra host (operator correction
|
|
327
350
|
2026-08-05: "active tab" = Active Processes). Single mount, shared stream. */}
|
|
File without changes
|
|
@@ -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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -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
|
);
|
|
File without changes
|
|
@@ -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(
|
package/src/stations/types.ts
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/src/style/app.css
CHANGED
|
@@ -3406,6 +3406,55 @@ button.vi-studio-src-thumb {
|
|
|
3406
3406
|
min-width: 0;
|
|
3407
3407
|
}
|
|
3408
3408
|
|
|
3409
|
+
.vi-studio-workflow {
|
|
3410
|
+
display: grid;
|
|
3411
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
3412
|
+
gap: 0.5rem;
|
|
3413
|
+
border-bottom: 1px solid var(--vi-border);
|
|
3414
|
+
padding-bottom: 0.7rem;
|
|
3415
|
+
}
|
|
3416
|
+
|
|
3417
|
+
.vi-studio-workflow button {
|
|
3418
|
+
display: flex;
|
|
3419
|
+
flex-direction: column;
|
|
3420
|
+
gap: 0.25rem;
|
|
3421
|
+
text-align: left;
|
|
3422
|
+
min-width: 0;
|
|
3423
|
+
padding: 0.65rem 0.75rem;
|
|
3424
|
+
border: 1px solid var(--vi-border);
|
|
3425
|
+
border-radius: 8px;
|
|
3426
|
+
background: var(--vi-surface-raised);
|
|
3427
|
+
color: var(--vi-text-dim);
|
|
3428
|
+
cursor: pointer;
|
|
3429
|
+
}
|
|
3430
|
+
|
|
3431
|
+
.vi-studio-workflow button:hover,
|
|
3432
|
+
.vi-studio-workflow button:focus-visible {
|
|
3433
|
+
border-color: var(--vi-accent);
|
|
3434
|
+
color: var(--vi-text);
|
|
3435
|
+
}
|
|
3436
|
+
|
|
3437
|
+
.vi-studio-workflow-step {
|
|
3438
|
+
color: var(--vi-text);
|
|
3439
|
+
font-family: var(--vi-mono);
|
|
3440
|
+
font-size: 0.76rem;
|
|
3441
|
+
font-weight: 600;
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
.vi-studio-workflow-target { scroll-margin-top: 1rem; min-width: 0; }
|
|
3445
|
+
.vi-studio-bin-empty {
|
|
3446
|
+
margin: 0;
|
|
3447
|
+
padding: 0.7rem 0.85rem;
|
|
3448
|
+
border: 1px dashed var(--vi-border);
|
|
3449
|
+
border-radius: 8px;
|
|
3450
|
+
color: var(--vi-text-dim);
|
|
3451
|
+
font-size: 0.78rem;
|
|
3452
|
+
}
|
|
3453
|
+
|
|
3454
|
+
@media (max-width: 35rem) {
|
|
3455
|
+
.vi-studio-workflow { grid-template-columns: 1fr; }
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3409
3458
|
/* Round 9: the studio no longer carries its OWN sidebar — it folds into the arm shell's
|
|
3410
3459
|
ONE left sidebar (Settings / Active / Library, via the sidebar registry). So the
|
|
3411
3460
|
workbench is a SINGLE centre column (viewer on top → generator below), the Movie
|
package/src/transport/client.ts
CHANGED
|
File without changes
|
|
File without changes
|
|
File without changes
|
package/src/video/LengthRow.tsx
CHANGED
|
File without changes
|
|
File without changes
|
|
@@ -71,12 +71,13 @@ export function PromptAssistButtons({
|
|
|
71
71
|
>
|
|
72
72
|
<option value="">fleet default</option>
|
|
73
73
|
{models.map((m) => (
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
// as a hang.
|
|
74
|
+
// Only call a model "cold" when the server explicitly reports cold.
|
|
75
|
+
// Older responses lack state; use serving/held before saying unknown.
|
|
77
76
|
<option key={m.model} value={m.model}>
|
|
78
77
|
{m.model}
|
|
79
|
-
{m.serving
|
|
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"}
|
|
80
81
|
</option>
|
|
81
82
|
))}
|
|
82
83
|
</select>
|
|
File without changes
|
|
File without changes
|
package/src/video/contract.ts
CHANGED
|
File without changes
|
|
File without changes
|
package/src/video/jobClock.ts
CHANGED
|
File without changes
|
|
@@ -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
|
+
}
|