@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,1224 @@
|
|
|
1
|
+
// The static left sidebar of the Studio workbench, split into two switchable
|
|
2
|
+
// tabs (role="tablist" above the tab bodies):
|
|
3
|
+
//
|
|
4
|
+
// • Active Processes — the session-wide "memory of processes": every in-flight
|
|
5
|
+
// or recently-relevant tracked job (live status/spinner via isJobActive, a
|
|
6
|
+
// Check-again for capped jobs, and a Cancel affordance wired to cancelJob).
|
|
7
|
+
// • Session Library — the completed outputs. Generate-station outputs are
|
|
8
|
+
// GROUPED by their producing run (expandable), each group annotated with the
|
|
9
|
+
// prompt + model that produced it and offering Continue (extend a scene from
|
|
10
|
+
// its last frame) / Replicate (re-stage prompt+model) — both wired through
|
|
11
|
+
// the composer seam (requestStage), NOT a parallel generation path. Every
|
|
12
|
+
// output row keeps the existing Add-to-prompt / assess / Remove affordances.
|
|
13
|
+
// Non-generation media (frames, crops, uploads) list flat below the groups.
|
|
14
|
+
//
|
|
15
|
+
// This is still a VIEW over the library + tracker seams: it never enqueues jobs
|
|
16
|
+
// and never DELETES server media. "Remove" / "Clear all" forget LOCAL entries
|
|
17
|
+
// only (house policy: no deletion) — EXCEPT for a produced STUDIO clip (genKind
|
|
18
|
+
// "studio_i2v"), which is the one library-family item with real server identity
|
|
19
|
+
// (a job_id in the durable clips catalog). For those, Remove/Clear-all ARCHIVE it
|
|
20
|
+
// server side too (POST studioClipArchiveUrl — never-delete: the clip's bytes and
|
|
21
|
+
// bus row are untouched, only hidden from the catalog list, reversible via the
|
|
22
|
+
// matching unarchive route) — otherwise the clip's own list poll (studioShared.tsx
|
|
23
|
+
// useStudioClips, ~6s) re-adds it to this library on the very next tick, which is
|
|
24
|
+
// exactly the reported "removed clips just reappear" bug. Everything else (frames,
|
|
25
|
+
// crops, uploads, generate_image/scene/movie outputs) has no durable per-item
|
|
26
|
+
// catalog to resurrect FROM, so those stay a clean local forget. Only the server
|
|
27
|
+
// MediaRef is retained — pixels are never persisted — so "assess" opens the live
|
|
28
|
+
// server asset (valid for the session); Continue/Replicate work off the retained
|
|
29
|
+
// prompt+model regardless.
|
|
30
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
31
|
+
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
|
32
|
+
import {
|
|
33
|
+
useMediaLibrary,
|
|
34
|
+
removeFromLibrary,
|
|
35
|
+
clearLibrary,
|
|
36
|
+
type LibraryItem,
|
|
37
|
+
type LibraryGenKind,
|
|
38
|
+
} from "../video/mediaLibrary";
|
|
39
|
+
import {
|
|
40
|
+
useJobTracker,
|
|
41
|
+
resumeJob,
|
|
42
|
+
cancelJob,
|
|
43
|
+
isJobActive,
|
|
44
|
+
isJobTerminal,
|
|
45
|
+
dismissAllTerminal,
|
|
46
|
+
type TrackedJob,
|
|
47
|
+
type StationId,
|
|
48
|
+
} from "../video/jobTracker";
|
|
49
|
+
import { STILL_RUNNING_MESSAGE } from "./pollCaps";
|
|
50
|
+
import { requestAddPart, requestStage } from "../video/composerBridge";
|
|
51
|
+
import { requestStudioStage } from "../video/studioBridge";
|
|
52
|
+
import { useWorkerCalls, type WorkerCall } from "../video/useWorkerCalls";
|
|
53
|
+
import { useSidebarRegistry } from "./SidebarRegistry";
|
|
54
|
+
import { WorkbenchDrawer } from "./WorkbenchDrawer";
|
|
55
|
+
import { SharePanel } from "./SharePanel";
|
|
56
|
+
import {
|
|
57
|
+
mediaBytesUrl,
|
|
58
|
+
studioClipArchiveUrl,
|
|
59
|
+
llmJobCancelUrl,
|
|
60
|
+
} from "../config";
|
|
61
|
+
import { request, errorOf, describeAppError } from "../transport/client";
|
|
62
|
+
import type { MediaRef } from "../video/contract";
|
|
63
|
+
|
|
64
|
+
/** Which sidebar tab is showing. Round 9: "settings" is guarded — it only appears
|
|
65
|
+
* when the mounted station has registered knobs into the sidebar registry. */
|
|
66
|
+
type SideTab = "processes" | "library" | "settings";
|
|
67
|
+
|
|
68
|
+
// Human station names for the process rows (mirrors the registry titles).
|
|
69
|
+
const STATION_NAME: Record<StationId, string> = {
|
|
70
|
+
"image-crop": "Image Crop",
|
|
71
|
+
"video-crop": "Video Crop",
|
|
72
|
+
"audio-crop": "Audio Crop",
|
|
73
|
+
frames: "Frames & Models",
|
|
74
|
+
generate: "Generate",
|
|
75
|
+
"generate-tester": "Generate (tester)",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// The route a process row links to. It is the station id by default (id === route
|
|
79
|
+
// path for the routed stations), but "video-crop" has NO route of its own — a video
|
|
80
|
+
// crop is an INLINE op on a studio item (Studio slice 2b), so its process row links
|
|
81
|
+
// to the studio workbench where that item lives instead of falling through to the
|
|
82
|
+
// default studio sub-tab.
|
|
83
|
+
const STATION_ROUTE: Partial<Record<StationId, string>> = {
|
|
84
|
+
"video-crop": "studio-clips",
|
|
85
|
+
// The tester is a surface INSIDE the Generate station, not a route of its own —
|
|
86
|
+
// without this the default (id === path) would link a tester row to /generate-tester,
|
|
87
|
+
// which resolves to nothing.
|
|
88
|
+
"generate-tester": "generate",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// mm:ss elapsed since enqueue.
|
|
92
|
+
function fmtElapsed(ms: number): string {
|
|
93
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
94
|
+
const m = Math.floor(total / 60);
|
|
95
|
+
const s = total % 60;
|
|
96
|
+
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// One active/recent process row — the always-visible "memory of processes". It
|
|
100
|
+
// links to its station sub-tab; a capped row exposes the verbatim still-running
|
|
101
|
+
// copy + a Check-again button, and an active row exposes a Cancel button — both
|
|
102
|
+
// outside the link so they don't also navigate.
|
|
103
|
+
function ProcessRow({ job, now }: { job: TrackedJob; now: number }) {
|
|
104
|
+
const statusKey = job.expired ? "failed" : job.status ?? "queued";
|
|
105
|
+
const statusText = job.expired ? "unknown" : job.status ?? "queued";
|
|
106
|
+
const active = isJobActive(job);
|
|
107
|
+
return (
|
|
108
|
+
<li className="vi-proc-item">
|
|
109
|
+
<NavLink to={`/${STATION_ROUTE[job.station] ?? job.station}`} className="vi-proc-link">
|
|
110
|
+
<div className="vi-proc-top">
|
|
111
|
+
{active ? (
|
|
112
|
+
<span className="vi-proc-spinner" aria-hidden />
|
|
113
|
+
) : (
|
|
114
|
+
<span className="vi-proc-dot" aria-hidden />
|
|
115
|
+
)}
|
|
116
|
+
<span className="vi-proc-station">{STATION_NAME[job.station]}</span>
|
|
117
|
+
<span className={`vi-status vi-status-${statusKey}`}>{statusText}</span>
|
|
118
|
+
{active && job.progress && (
|
|
119
|
+
<span className="vi-proc-progress">
|
|
120
|
+
image {job.progress.done}/{job.progress.total}
|
|
121
|
+
</span>
|
|
122
|
+
)}
|
|
123
|
+
<span className="vi-proc-elapsed">{fmtElapsed(now - job.enqueuedAt)}</span>
|
|
124
|
+
</div>
|
|
125
|
+
<span className="vi-proc-label">{job.label}</span>
|
|
126
|
+
</NavLink>
|
|
127
|
+
{active && (
|
|
128
|
+
<div className="vi-proc-msg">
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
className="vi-btn vi-btn-sm vi-proc-btn"
|
|
132
|
+
disabled={job.status === "cancelling"}
|
|
133
|
+
onClick={() => cancelJob(job.jobId)}
|
|
134
|
+
title="Stop this job — a frame already in flight finishes first"
|
|
135
|
+
>
|
|
136
|
+
{job.status === "cancelling" ? "Cancelling…" : "✕ Cancel"}
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
{job.capped && (
|
|
141
|
+
<div className="vi-proc-msg">
|
|
142
|
+
<span>{STILL_RUNNING_MESSAGE}</span>
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
className="vi-btn vi-btn-sm vi-proc-btn"
|
|
146
|
+
onClick={() => resumeJob(job.jobId)}
|
|
147
|
+
>
|
|
148
|
+
Check again
|
|
149
|
+
</button>
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
{job.status === "failed" && job.error && (
|
|
153
|
+
<span className="vi-proc-msg vi-proc-err">{job.error}</span>
|
|
154
|
+
)}
|
|
155
|
+
{job.expired && (
|
|
156
|
+
<span className="vi-proc-msg">
|
|
157
|
+
The server no longer tracks this job — it may have expired.
|
|
158
|
+
</span>
|
|
159
|
+
)}
|
|
160
|
+
</li>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Friendly names for the unified /llm/jobs feed's known media kinds; anything
|
|
165
|
+
// unmapped falls through to its raw kind (never blank).
|
|
166
|
+
const WORKER_CALL_KIND_LABEL: Record<string, string> = {
|
|
167
|
+
identity_reconstruction: "Identity render",
|
|
168
|
+
studio_i2v: "Studio clip",
|
|
169
|
+
generate_studio_movie: "Studio movie",
|
|
170
|
+
generate_scene: "Scene",
|
|
171
|
+
generate_movie: "Movie",
|
|
172
|
+
generate_image: "Image",
|
|
173
|
+
};
|
|
174
|
+
function humanizeWorkerKind(kind: string): string {
|
|
175
|
+
return WORKER_CALL_KIND_LABEL[kind] ?? (kind || "Worker call");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// A render is treated as STALLED (frozen, not merely slow) once it has gone this
|
|
179
|
+
// long with no observed movement — even if the server hasn't flagged it. This is
|
|
180
|
+
// the honest-status window: past it the row drops its live/green look for an amber
|
|
181
|
+
// stalled state, so a wedged render can never keep reading as active.
|
|
182
|
+
const WORKER_CALL_STALL_MS = 90_000;
|
|
183
|
+
|
|
184
|
+
// "12s" / "3m 04s" since some past moment — the expanded panel's relative
|
|
185
|
+
// last-movement clock (sibling of fmtElapsed, which counts UP from start).
|
|
186
|
+
function fmtAgo(ms: number): string {
|
|
187
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
188
|
+
if (total < 60) return `${total}s`;
|
|
189
|
+
const m = Math.floor(total / 60);
|
|
190
|
+
return `${m}m ${(total % 60).toString().padStart(2, "0")}s`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The scrolling log tail in the expanded panel: a monospace <pre> capped by CSS
|
|
194
|
+
// max-height with its own scroll, pinned to the BOTTOM (newest line) whenever the
|
|
195
|
+
// tail changes — so a live render's newest output is always in view without the
|
|
196
|
+
// operator chasing it. Empty/absent tail degrades to a quiet "no log yet".
|
|
197
|
+
function WorkerCallLog({ lines }: { lines: string[] }) {
|
|
198
|
+
const ref = useRef<HTMLPreElement | null>(null);
|
|
199
|
+
// Re-pin only when the content actually changes (count + newest line), not on
|
|
200
|
+
// every parent re-render — otherwise a manual scroll-up would be yanked back
|
|
201
|
+
// down every poll tick.
|
|
202
|
+
const key = lines.length ? `${lines.length}:${lines[lines.length - 1]}` : "";
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
const el = ref.current;
|
|
205
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
206
|
+
}, [key]);
|
|
207
|
+
if (lines.length === 0) {
|
|
208
|
+
return <p className="vi-proc-log-empty">No log yet.</p>;
|
|
209
|
+
}
|
|
210
|
+
return (
|
|
211
|
+
<pre className="vi-proc-log" ref={ref} aria-label="Recent log output">
|
|
212
|
+
{lines.join("\n")}
|
|
213
|
+
</pre>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// One in-flight WORKER CALL row (from the fleet-wide /llm/jobs feed) — the calls
|
|
218
|
+
// this browser session did NOT launch (identity reconstruction, another station's
|
|
219
|
+
// render, another transport). Presentational sibling of ProcessRow: no NavLink
|
|
220
|
+
// (these jobs have no station route here), a unified hard-Cancel, and — unlike the
|
|
221
|
+
// old flat row that read green-active even when wedged — an EXPANDABLE panel with a
|
|
222
|
+
// live progress bar, current stage, an honest stalled state, and a scrolling log.
|
|
223
|
+
function WorkerCallRow({
|
|
224
|
+
call,
|
|
225
|
+
now,
|
|
226
|
+
cancelling,
|
|
227
|
+
expanded,
|
|
228
|
+
onToggle,
|
|
229
|
+
onCancel,
|
|
230
|
+
}: {
|
|
231
|
+
call: WorkerCall;
|
|
232
|
+
now: number;
|
|
233
|
+
cancelling: boolean;
|
|
234
|
+
expanded: boolean;
|
|
235
|
+
onToggle: (id: string) => void;
|
|
236
|
+
onCancel: (call: WorkerCall) => void;
|
|
237
|
+
}) {
|
|
238
|
+
const meta = [call.model, call.worker].filter(Boolean).join(" · ");
|
|
239
|
+
// Honest stalled signal: the server said so, OR movement has been silent past the
|
|
240
|
+
// stall window. Recomputed off `now` (the ~1s panel tick), so a live render that
|
|
241
|
+
// freezes flips to stalled on its own without a status change.
|
|
242
|
+
const lastMoveMs = call.updatedAt != null ? Math.max(0, now - call.updatedAt) : null;
|
|
243
|
+
const stalled =
|
|
244
|
+
call.stalled || (lastMoveMs != null && lastMoveMs > WORKER_CALL_STALL_MS);
|
|
245
|
+
const pct = call.progress != null ? Math.round(call.progress * 100) : null;
|
|
246
|
+
// A requested-and-acknowledged cancel also latches the button into "cancelling…"
|
|
247
|
+
// (survives across polls until the row drops), not just the local in-flight click.
|
|
248
|
+
const cancelBusy = cancelling || call.cancelRequested;
|
|
249
|
+
return (
|
|
250
|
+
<li className="vi-proc-item vi-proc-item-worker">
|
|
251
|
+
<div className="vi-proc-top">
|
|
252
|
+
<button
|
|
253
|
+
type="button"
|
|
254
|
+
className="vi-proc-toggle"
|
|
255
|
+
aria-expanded={expanded}
|
|
256
|
+
aria-label={expanded ? "Collapse details" : "Expand — progress, stage, log"}
|
|
257
|
+
onClick={() => onToggle(call.id)}
|
|
258
|
+
>
|
|
259
|
+
<span className="vi-proc-caret" aria-hidden>
|
|
260
|
+
{expanded ? "▾" : "▸"}
|
|
261
|
+
</span>
|
|
262
|
+
</button>
|
|
263
|
+
{stalled ? (
|
|
264
|
+
<span className="vi-proc-stall-dot" aria-hidden />
|
|
265
|
+
) : (
|
|
266
|
+
<span className="vi-proc-spinner" aria-hidden />
|
|
267
|
+
)}
|
|
268
|
+
<span className="vi-proc-station">{humanizeWorkerKind(call.kind)}</span>
|
|
269
|
+
<span className={`vi-status vi-status-${call.status}`}>{call.status}</span>
|
|
270
|
+
{stalled && <span className="vi-proc-stall-badge">stalled</span>}
|
|
271
|
+
{pct != null && <span className="vi-proc-progress">{pct}%</span>}
|
|
272
|
+
<span className="vi-proc-elapsed">{fmtElapsed(now - call.startedAt)}</span>
|
|
273
|
+
</div>
|
|
274
|
+
{meta && <span className="vi-proc-label">{meta}</span>}
|
|
275
|
+
|
|
276
|
+
{expanded && (
|
|
277
|
+
<div className="vi-proc-detail">
|
|
278
|
+
{/* Progress bar: determinate when the feed gives a fraction, otherwise an
|
|
279
|
+
indeterminate "working" sweep — never a fabricated percentage. Goes
|
|
280
|
+
amber when stalled so a frozen bar doesn't read as healthy progress. */}
|
|
281
|
+
<div className="vi-proc-progbar-row">
|
|
282
|
+
<div
|
|
283
|
+
className={`vi-proc-progbar${
|
|
284
|
+
pct == null ? " vi-proc-progbar-indeterminate" : ""
|
|
285
|
+
}${stalled ? " vi-proc-progbar-stalled" : ""}`}
|
|
286
|
+
role="progressbar"
|
|
287
|
+
aria-valuemin={0}
|
|
288
|
+
aria-valuemax={100}
|
|
289
|
+
{...(pct != null ? { "aria-valuenow": pct } : {})}
|
|
290
|
+
>
|
|
291
|
+
<span
|
|
292
|
+
className="vi-proc-progbar-fill"
|
|
293
|
+
style={pct != null ? { width: `${pct}%` } : undefined}
|
|
294
|
+
/>
|
|
295
|
+
</div>
|
|
296
|
+
<span className="vi-proc-stage">
|
|
297
|
+
{call.stage ? call.stage : pct == null ? "working…" : `${pct}%`}
|
|
298
|
+
</span>
|
|
299
|
+
</div>
|
|
300
|
+
|
|
301
|
+
{call.message && <p className="vi-proc-detail-msg">{call.message}</p>}
|
|
302
|
+
|
|
303
|
+
<div className="vi-proc-detail-meta">
|
|
304
|
+
<span
|
|
305
|
+
className={`vi-proc-lastmove${
|
|
306
|
+
stalled ? " vi-proc-lastmove-stalled" : ""
|
|
307
|
+
}`}
|
|
308
|
+
>
|
|
309
|
+
{lastMoveMs != null
|
|
310
|
+
? `last movement ${fmtAgo(lastMoveMs)} ago`
|
|
311
|
+
: "last movement unknown"}
|
|
312
|
+
</span>
|
|
313
|
+
</div>
|
|
314
|
+
|
|
315
|
+
<WorkerCallLog lines={call.logTail} />
|
|
316
|
+
</div>
|
|
317
|
+
)}
|
|
318
|
+
|
|
319
|
+
<div className="vi-proc-msg">
|
|
320
|
+
<button
|
|
321
|
+
type="button"
|
|
322
|
+
className="vi-btn vi-btn-sm vi-proc-btn"
|
|
323
|
+
disabled={cancelBusy}
|
|
324
|
+
onClick={() => onCancel(call)}
|
|
325
|
+
title="Stop this worker call — a step already in flight finishes first"
|
|
326
|
+
>
|
|
327
|
+
{cancelBusy ? "Cancelling…" : "✕ Cancel"}
|
|
328
|
+
</button>
|
|
329
|
+
</div>
|
|
330
|
+
</li>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// STUDIO SCOPE (operator, 2026-07-31): the studio workbench's Active Processes must
|
|
335
|
+
// list ONLY studio-originated work — NEVER a general fleet model call (a discord chat,
|
|
336
|
+
// a web /v1 completion, another transport's LLM call). Those must not be visible here
|
|
337
|
+
// and must not be cancellable from inside the studio. The fleet-wide /llm/jobs feed
|
|
338
|
+
// carries every transport, so we keep only rows that are RENDER/MEDIA work:
|
|
339
|
+
// * transport === "media" — the media/render bus (the authoritative signal: every
|
|
340
|
+
// job on it is video-arm work — studio clips/movies, scene/image gen, identity
|
|
341
|
+
// reconstruction/mesh/char360, mlt render), OR
|
|
342
|
+
// * a known render/media job KIND (defensive fallback if a row's transport is blank).
|
|
343
|
+
// A general LLM call (transport web/v1/discord/cli, kind chat/completion/…) is dropped.
|
|
344
|
+
// The console-wide ActiveProcessesStation still shows the fleet superset — this scoping
|
|
345
|
+
// is the studio sidebar's alone.
|
|
346
|
+
const STUDIO_MEDIA_KINDS = new Set([
|
|
347
|
+
"studio_i2v",
|
|
348
|
+
"generate_studio_movie",
|
|
349
|
+
"generate_scene",
|
|
350
|
+
"generate_movie",
|
|
351
|
+
"generate_image",
|
|
352
|
+
"identity_reconstruction",
|
|
353
|
+
"identity_mesh_build",
|
|
354
|
+
"identity_video_extract",
|
|
355
|
+
"mlt_render",
|
|
356
|
+
"crop",
|
|
357
|
+
"frame_extract",
|
|
358
|
+
"audio_extract",
|
|
359
|
+
]);
|
|
360
|
+
|
|
361
|
+
function isStudioOriginated(c: WorkerCall): boolean {
|
|
362
|
+
return c.transport === "media" || STUDIO_MEDIA_KINDS.has(c.kind);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Non-done tracked jobs, newest first — the Active Processes tab body. */
|
|
366
|
+
function activeJobsOf(records: TrackedJob[]): TrackedJob[] {
|
|
367
|
+
return records
|
|
368
|
+
.filter((r) => r.status !== "done")
|
|
369
|
+
.slice()
|
|
370
|
+
.sort((a, b) => b.enqueuedAt - a.enqueuedAt);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// The Active Processes tab body: every non-done tracked job, newest first. Done
|
|
374
|
+
// rows drop off once their artifact is in the library tab. Ticks ~1s so elapsed
|
|
375
|
+
// stays live while anything is running or paused-capped.
|
|
376
|
+
function ProcessesPanel() {
|
|
377
|
+
const reg = useSidebarRegistry();
|
|
378
|
+
const records = useJobTracker();
|
|
379
|
+
const jobs = useMemo(() => activeJobsOf(records), [records]);
|
|
380
|
+
|
|
381
|
+
// The fleet-wide in-flight worker calls (identity reconstruction, other stations'
|
|
382
|
+
// renders, calls from any transport) — the work THIS browser session never
|
|
383
|
+
// launched, so the session jobTracker can't see it. Merged below, DEDUPED against
|
|
384
|
+
// the tracker: a /llm/jobs row whose id equals a TrackedJob.jobId is dropped (the
|
|
385
|
+
// tracker row is richer — station link, progress, provenance — so it wins).
|
|
386
|
+
const { calls, refetch } = useWorkerCalls();
|
|
387
|
+
const trackerIds = useMemo(() => new Set(jobs.map((j) => j.jobId)), [jobs]);
|
|
388
|
+
const workerCalls = useMemo(
|
|
389
|
+
// Deduped against the session tracker AND scoped to studio-originated (media/
|
|
390
|
+
// render) work only — a general fleet model call never surfaces or cancels here.
|
|
391
|
+
() => calls.filter((c) => !trackerIds.has(c.id) && isStudioOriginated(c)),
|
|
392
|
+
[calls, trackerIds],
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
// Terminal rows (failed / cancelled / expired) linger in this list after the
|
|
396
|
+
// active ones drop off — this drives the opt-in "Clear finished" broom below.
|
|
397
|
+
const hasTerminal = useMemo(() => jobs.some(isJobTerminal), [jobs]);
|
|
398
|
+
|
|
399
|
+
// Per-id busy flag for the worker-call Cancel buttons (immediate feedback; the
|
|
400
|
+
// next poll / the forced refetch drops the row once the server observes it).
|
|
401
|
+
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
|
|
402
|
+
const onCancelWorkerCall = useCallback(
|
|
403
|
+
async (call: WorkerCall) => {
|
|
404
|
+
setCancelling((prev) => new Set(prev).add(call.id));
|
|
405
|
+
// ONE unified hard-cancel for every transport: the feed's own cancel route
|
|
406
|
+
// (POST /llm/jobs/<id>/cancel → {cancelled,status}) fans the stop out to the
|
|
407
|
+
// right bus server-side, so the row no longer has to route media vs chat itself.
|
|
408
|
+
await request<unknown>(llmJobCancelUrl(call.id), {
|
|
409
|
+
method: "POST",
|
|
410
|
+
meta: { specKey: "workers", operation: "worker.call.cancel" },
|
|
411
|
+
});
|
|
412
|
+
refetch();
|
|
413
|
+
setCancelling((prev) => {
|
|
414
|
+
const n = new Set(prev);
|
|
415
|
+
n.delete(call.id);
|
|
416
|
+
return n;
|
|
417
|
+
});
|
|
418
|
+
},
|
|
419
|
+
[refetch],
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
// Per-id expanded state for the worker-call rows (progress / stage / stalled /
|
|
423
|
+
// log). Keyed by job id so a row's panel stays open across poll ticks; a job that
|
|
424
|
+
// finishes and drops from the feed just leaves a stale id in the set (harmless).
|
|
425
|
+
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
426
|
+
const toggleExpanded = useCallback((id: string) => {
|
|
427
|
+
setExpanded((prev) => {
|
|
428
|
+
const n = new Set(prev);
|
|
429
|
+
if (n.has(id)) n.delete(id);
|
|
430
|
+
else n.add(id);
|
|
431
|
+
return n;
|
|
432
|
+
});
|
|
433
|
+
}, []);
|
|
434
|
+
|
|
435
|
+
// Tick ~1s while anything is live so every elapsed clock stays current: tracker
|
|
436
|
+
// actives/capped OR any in-flight worker call (those are always live).
|
|
437
|
+
const ticking =
|
|
438
|
+
jobs.some((r) => isJobActive(r) || r.capped) || workerCalls.length > 0;
|
|
439
|
+
const [now, setNow] = useState(() => Date.now());
|
|
440
|
+
useEffect(() => {
|
|
441
|
+
if (!ticking) {
|
|
442
|
+
setNow(Date.now());
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const id = window.setInterval(() => setNow(Date.now()), 1000);
|
|
446
|
+
return () => window.clearInterval(id);
|
|
447
|
+
}, [ticking]);
|
|
448
|
+
|
|
449
|
+
// Round 9: the mounted station (e.g. STUDIO — whose jobs are not in the jobTracker)
|
|
450
|
+
// portals its own in-flight rows into this host, so the ONE Active Processes view
|
|
451
|
+
// shows tracker jobs AND studio renders. The host is ALWAYS rendered so the portal
|
|
452
|
+
// target is stable; the empty copy is suppressed when a station has portaled rows in.
|
|
453
|
+
const extraHost = <div className="vi-proc-extra" ref={reg.setActiveExtraHost} />;
|
|
454
|
+
|
|
455
|
+
const total = jobs.length + workerCalls.length;
|
|
456
|
+
|
|
457
|
+
if (total === 0) {
|
|
458
|
+
return (
|
|
459
|
+
<>
|
|
460
|
+
{!reg.activeExtraActive && (
|
|
461
|
+
<p className="vi-lib-empty">
|
|
462
|
+
No active processes — jobs you run appear here with live status until they
|
|
463
|
+
finish, then their outputs land in the Session Library.
|
|
464
|
+
</p>
|
|
465
|
+
)}
|
|
466
|
+
{extraHost}
|
|
467
|
+
</>
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return (
|
|
472
|
+
<>
|
|
473
|
+
<div className="vi-proc-header">
|
|
474
|
+
<h2 className="vi-proc-title">Active processes</h2>
|
|
475
|
+
<span className="vi-proc-count">{total}</span>
|
|
476
|
+
{/* Only shown when there's a finished/failed row to clear; forgets the
|
|
477
|
+
LOCAL terminal rows only (never server work), and leaves every
|
|
478
|
+
still-running job polling and visible. */}
|
|
479
|
+
{hasTerminal && (
|
|
480
|
+
<button
|
|
481
|
+
type="button"
|
|
482
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
483
|
+
onClick={() => dismissAllTerminal()}
|
|
484
|
+
title="Remove finished and failed items from this view — your saved files on disk are kept."
|
|
485
|
+
aria-label="Remove finished and failed jobs from this list — still-running jobs stay, and your saved files on disk are kept."
|
|
486
|
+
>
|
|
487
|
+
Clear finished
|
|
488
|
+
</button>
|
|
489
|
+
)}
|
|
490
|
+
</div>
|
|
491
|
+
<ul className="vi-proc-list">
|
|
492
|
+
{jobs.map((job) => (
|
|
493
|
+
<ProcessRow key={job.jobId} job={job} now={now} />
|
|
494
|
+
))}
|
|
495
|
+
{workerCalls.map((call) => (
|
|
496
|
+
<WorkerCallRow
|
|
497
|
+
key={call.id}
|
|
498
|
+
call={call}
|
|
499
|
+
now={now}
|
|
500
|
+
cancelling={cancelling.has(call.id)}
|
|
501
|
+
expanded={expanded.has(call.id)}
|
|
502
|
+
onToggle={toggleExpanded}
|
|
503
|
+
onCancel={onCancelWorkerCall}
|
|
504
|
+
/>
|
|
505
|
+
))}
|
|
506
|
+
</ul>
|
|
507
|
+
{extraHost}
|
|
508
|
+
</>
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// The kind-driven media preview, shared verbatim by the library rows AND the
|
|
513
|
+
// collapsed group header (so the two thumbnail renderers can never drift apart):
|
|
514
|
+
// images get a lazy thumbnail, video gets a poster-like frame still (a muted,
|
|
515
|
+
// metadata-only <video> seeked to an early frame — there is no server-side poster
|
|
516
|
+
// to lean on; /video/media is Range-aware so only the seeked byte range is
|
|
517
|
+
// fetched), audio gets an inline player, and anything else (unknown) gets a cheap
|
|
518
|
+
// placeholder tile. `className` names the image/video tile wrapper so a compact
|
|
519
|
+
// caller (the group header, .vi-lib-group-thumb) can size it smaller while the
|
|
520
|
+
// rows keep the full-width .vi-lib-thumb. NOTE: the prop is `media` (not `ref`) —
|
|
521
|
+
// `ref` is a reserved prop name on React 18 function components and would not be
|
|
522
|
+
// forwarded as a normal prop.
|
|
523
|
+
function RefThumb({
|
|
524
|
+
media,
|
|
525
|
+
name,
|
|
526
|
+
className = "vi-lib-thumb",
|
|
527
|
+
}: {
|
|
528
|
+
media: MediaRef;
|
|
529
|
+
name: string;
|
|
530
|
+
className?: string;
|
|
531
|
+
}) {
|
|
532
|
+
const kind = media.kind;
|
|
533
|
+
if (kind === "image") {
|
|
534
|
+
return (
|
|
535
|
+
<div className={className}>
|
|
536
|
+
<img src={mediaBytesUrl(media.uri)} loading="lazy" alt={name} />
|
|
537
|
+
</div>
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
if (kind === "video") {
|
|
541
|
+
return (
|
|
542
|
+
<div className={className}>
|
|
543
|
+
<video
|
|
544
|
+
src={`${mediaBytesUrl(media.uri)}#t=0.1`}
|
|
545
|
+
muted
|
|
546
|
+
playsInline
|
|
547
|
+
preload="metadata"
|
|
548
|
+
aria-label={name}
|
|
549
|
+
/>
|
|
550
|
+
</div>
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
if (kind === "audio") {
|
|
554
|
+
return (
|
|
555
|
+
<audio
|
|
556
|
+
className="vi-audio-player"
|
|
557
|
+
controls
|
|
558
|
+
preload="none"
|
|
559
|
+
src={mediaBytesUrl(media.uri)}
|
|
560
|
+
/>
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
return (
|
|
564
|
+
<div className="vi-lib-placeholder" aria-hidden>
|
|
565
|
+
<span>{kind}</span>
|
|
566
|
+
</div>
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// A single library row. Kind drives the preview: images get a lazy thumbnail,
|
|
571
|
+
// video gets a poster-like frame still (a muted metadata-only <video> seeked to an
|
|
572
|
+
// early frame — there is no server-side thumbnail to lean on), audio gets an inline
|
|
573
|
+
// player, and anything else (unknown) gets a cheap placeholder tile.
|
|
574
|
+
function LibraryRow({ it, onGenerate }: { it: LibraryItem; onGenerate: boolean }) {
|
|
575
|
+
const navigate = useNavigate();
|
|
576
|
+
const { ref } = it;
|
|
577
|
+
// label wins, then origin, then a short asset-id stub (|| so blanks fall through).
|
|
578
|
+
const name = it.label || it.origin || ref.asset_id.slice(0, 8);
|
|
579
|
+
const kind = ref.kind;
|
|
580
|
+
const canGenerate = kind === "image" || kind === "video";
|
|
581
|
+
const canAssess = kind === "image" || kind === "video";
|
|
582
|
+
// The B2 movie->studio chain: any VIDEO ref (a movie/scene mp4, or a produced
|
|
583
|
+
// studio clip) can seed a studio i2v render that EXTENDS it from its last frame.
|
|
584
|
+
const canSendToStudio = kind === "video";
|
|
585
|
+
// A produced STUDIO clip is the one library item with real SERVER identity —
|
|
586
|
+
// its job_id rides along as `groupId` (see studioShared.tsx's addToLibrary
|
|
587
|
+
// call). Remove must archive that job server-side (see this file's header
|
|
588
|
+
// note) instead of only forgetting the local entry, or the clip list's ~6s
|
|
589
|
+
// poll re-adds it on the very next tick.
|
|
590
|
+
const studioJobId = it.genKind === "studio_i2v" ? it.groupId : undefined;
|
|
591
|
+
|
|
592
|
+
const dims =
|
|
593
|
+
ref.width != null && ref.height != null ? `${ref.width}×${ref.height}` : null;
|
|
594
|
+
const dur = ref.duration_s != null ? `${Math.round(ref.duration_s)}s` : null;
|
|
595
|
+
|
|
596
|
+
// Optimistic remove for the studio-clip path: the row hides IMMEDIATELY (before
|
|
597
|
+
// the archive call resolves) and only forgets the local entry once the server
|
|
598
|
+
// confirms it archived — so a failed/slow archive un-hides the row with the
|
|
599
|
+
// error shown, instead of the item silently vanishing while the server still
|
|
600
|
+
// lists it (which would just re-trigger the same resurrection bug one poll
|
|
601
|
+
// later). Non-studio items skip all of this — removeFromLibrary is a same-tick,
|
|
602
|
+
// can't-fail local operation for them, exactly as before.
|
|
603
|
+
const [hidden, setHidden] = useState(false);
|
|
604
|
+
const [removeError, setRemoveError] = useState<string | null>(null);
|
|
605
|
+
// In-place ("▸ Play here") playback for VIDEO rows — the same function as the
|
|
606
|
+
// studio viewer's Play here (StudioViewer.tsx). When true, the poster still is
|
|
607
|
+
// swapped for a real, audible, autoplaying inline <video> right here in the row
|
|
608
|
+
// (no new browser tab). Local to the row: each row plays independently by design.
|
|
609
|
+
const [playing, setPlaying] = useState(false);
|
|
610
|
+
|
|
611
|
+
async function handleRemove() {
|
|
612
|
+
if (!studioJobId) {
|
|
613
|
+
removeFromLibrary(ref.uri);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
setRemoveError(null);
|
|
617
|
+
setHidden(true);
|
|
618
|
+
const res = await request<unknown>(studioClipArchiveUrl(studioJobId), {
|
|
619
|
+
method: "POST",
|
|
620
|
+
meta: { specKey: "studio", operation: "studio.clip.archive" },
|
|
621
|
+
});
|
|
622
|
+
if (!res.ok) {
|
|
623
|
+
setHidden(false);
|
|
624
|
+
setRemoveError(describeAppError(errorOf(res)));
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
// Archived server-side (or already was — archive is idempotent): the clip is
|
|
628
|
+
// gone from GET .../clips for good, so forgetting the local entry now is
|
|
629
|
+
// permanent — no future poll can ever re-add it.
|
|
630
|
+
removeFromLibrary(ref.uri);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (hidden) return null;
|
|
634
|
+
|
|
635
|
+
return (
|
|
636
|
+
<li className="vi-lib-item">
|
|
637
|
+
<div className="vi-lib-top">
|
|
638
|
+
<span className="vi-lib-badge">{kind}</span>
|
|
639
|
+
<span className="vi-lib-name" title={ref.uri}>
|
|
640
|
+
{name}
|
|
641
|
+
</span>
|
|
642
|
+
</div>
|
|
643
|
+
|
|
644
|
+
{/* Preview extracted into <RefThumb> (shared with the group header). Movie /
|
|
645
|
+
scene / studio clips are video/mp4 and render a poster-like #t=0.1 still.
|
|
646
|
+
When a VIDEO row is "▸ Play here"-playing, swap that still for a real
|
|
647
|
+
inline player (audible, autoplaying) in the SAME slot + sizing wrapper
|
|
648
|
+
(.vi-lib-thumb) so the row doesn't jump — no #t=0.1, not muted, so it
|
|
649
|
+
actually plays with audio. */}
|
|
650
|
+
{kind === "video" && playing ? (
|
|
651
|
+
<div className="vi-lib-thumb">
|
|
652
|
+
<video
|
|
653
|
+
src={mediaBytesUrl(ref.uri)}
|
|
654
|
+
controls
|
|
655
|
+
autoPlay
|
|
656
|
+
playsInline
|
|
657
|
+
aria-label={name}
|
|
658
|
+
/>
|
|
659
|
+
</div>
|
|
660
|
+
) : (
|
|
661
|
+
<RefThumb media={ref} name={name} />
|
|
662
|
+
)}
|
|
663
|
+
|
|
664
|
+
<div className="vi-lib-meta">
|
|
665
|
+
{kind === "image" && dims ? <span>{dims}</span> : null}
|
|
666
|
+
{(kind === "audio" || kind === "video") && dur ? <span>{dur}</span> : null}
|
|
667
|
+
<code>{ref.mime}</code>
|
|
668
|
+
</div>
|
|
669
|
+
|
|
670
|
+
<div className="vi-lib-actions">
|
|
671
|
+
{kind === "video" && (
|
|
672
|
+
<button
|
|
673
|
+
type="button"
|
|
674
|
+
className={`vi-btn vi-btn-sm${playing ? "" : " vi-btn-accent"}`}
|
|
675
|
+
onClick={() => setPlaying((v) => !v)}
|
|
676
|
+
title={
|
|
677
|
+
playing
|
|
678
|
+
? "Stop inline playback"
|
|
679
|
+
: "Play this clip here, in place (same as the studio viewer's Play here) — no new tab"
|
|
680
|
+
}
|
|
681
|
+
>
|
|
682
|
+
{playing ? "■ Stop" : "▸ Play here"}
|
|
683
|
+
</button>
|
|
684
|
+
)}
|
|
685
|
+
{canGenerate ? (
|
|
686
|
+
onGenerate ? (
|
|
687
|
+
<button
|
|
688
|
+
type="button"
|
|
689
|
+
className="vi-btn vi-btn-sm vi-btn-accent"
|
|
690
|
+
onClick={() =>
|
|
691
|
+
requestAddPart({
|
|
692
|
+
ref,
|
|
693
|
+
origin: it.origin,
|
|
694
|
+
...(it.label != null ? { label: it.label } : {}),
|
|
695
|
+
})
|
|
696
|
+
}
|
|
697
|
+
>
|
|
698
|
+
Add to prompt
|
|
699
|
+
</button>
|
|
700
|
+
) : (
|
|
701
|
+
<button
|
|
702
|
+
type="button"
|
|
703
|
+
className="vi-btn vi-btn-sm vi-btn-accent"
|
|
704
|
+
onClick={() => {
|
|
705
|
+
// Stage the part FIRST — composerBridge BUFFERS it (Generate isn't
|
|
706
|
+
// mounted yet) — THEN navigate. GenerateStation drains the buffer on
|
|
707
|
+
// mount, so the item actually lands in the prompt instead of just
|
|
708
|
+
// being "available in the picker" (which read as doing nothing). Same
|
|
709
|
+
// effect as "Add to prompt", with the route hop.
|
|
710
|
+
requestAddPart({
|
|
711
|
+
ref,
|
|
712
|
+
origin: it.origin,
|
|
713
|
+
...(it.label != null ? { label: it.label } : {}),
|
|
714
|
+
});
|
|
715
|
+
navigate("/generate");
|
|
716
|
+
}}
|
|
717
|
+
title="Add this to the Generate prompt and open the Generate station."
|
|
718
|
+
>
|
|
719
|
+
Use in Generate
|
|
720
|
+
</button>
|
|
721
|
+
)
|
|
722
|
+
) : null}
|
|
723
|
+
{canSendToStudio && (
|
|
724
|
+
<>
|
|
725
|
+
<button
|
|
726
|
+
type="button"
|
|
727
|
+
className="vi-btn vi-btn-sm vi-btn-accent"
|
|
728
|
+
onClick={() => {
|
|
729
|
+
requestStudioStage({
|
|
730
|
+
sourceVideo: ref,
|
|
731
|
+
mode: "extend",
|
|
732
|
+
provenance: {
|
|
733
|
+
origin: it.origin,
|
|
734
|
+
prompt: it.prompt,
|
|
735
|
+
model: it.model,
|
|
736
|
+
genKind: it.genKind,
|
|
737
|
+
parentUri: ref.uri,
|
|
738
|
+
},
|
|
739
|
+
});
|
|
740
|
+
navigate("/studio-clips");
|
|
741
|
+
}}
|
|
742
|
+
title="Open Studio Clips staged to EXTEND this clip from its last frame (i2v, movie → studio chain)."
|
|
743
|
+
>
|
|
744
|
+
Send to Studio
|
|
745
|
+
</button>
|
|
746
|
+
<button
|
|
747
|
+
type="button"
|
|
748
|
+
className="vi-btn vi-btn-sm"
|
|
749
|
+
onClick={() => {
|
|
750
|
+
requestStudioStage({
|
|
751
|
+
sourceVideo: ref,
|
|
752
|
+
mode: "restyle",
|
|
753
|
+
provenance: {
|
|
754
|
+
origin: it.origin,
|
|
755
|
+
prompt: it.prompt,
|
|
756
|
+
model: it.model,
|
|
757
|
+
genKind: it.genKind,
|
|
758
|
+
parentUri: ref.uri,
|
|
759
|
+
},
|
|
760
|
+
});
|
|
761
|
+
navigate("/studio-clips");
|
|
762
|
+
}}
|
|
763
|
+
title="Open Studio Clips staged to RESTYLE this clip with a prompt (v2v · Wan VACE, 480p landscape)."
|
|
764
|
+
>
|
|
765
|
+
Restyle
|
|
766
|
+
</button>
|
|
767
|
+
</>
|
|
768
|
+
)}
|
|
769
|
+
{canAssess && (
|
|
770
|
+
<a
|
|
771
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
772
|
+
href={mediaBytesUrl(ref.uri)}
|
|
773
|
+
target="_blank"
|
|
774
|
+
rel="noreferrer"
|
|
775
|
+
title="Open this output at full resolution in a new tab (read-only)."
|
|
776
|
+
>
|
|
777
|
+
assess
|
|
778
|
+
</a>
|
|
779
|
+
)}
|
|
780
|
+
<button
|
|
781
|
+
type="button"
|
|
782
|
+
className="vi-btn vi-btn-sm"
|
|
783
|
+
onClick={() => void handleRemove()}
|
|
784
|
+
title={
|
|
785
|
+
studioJobId
|
|
786
|
+
? "Archive this clip on the server (never deleted, reversible) and remove it here — it won't come back."
|
|
787
|
+
: "Remove from the library for good — tombstoned so it can't repopulate. The server asset is untouched (not deleted)."
|
|
788
|
+
}
|
|
789
|
+
aria-label={`Remove ${name} from the session library for good${
|
|
790
|
+
studioJobId ? " (archives the clip on the server)" : " (won't repopulate)"
|
|
791
|
+
}`}
|
|
792
|
+
>
|
|
793
|
+
Remove
|
|
794
|
+
</button>
|
|
795
|
+
</div>
|
|
796
|
+
{removeError && (
|
|
797
|
+
<p className="vi-lib-error" role="alert">
|
|
798
|
+
Couldn't archive — {removeError} (row restored, try again)
|
|
799
|
+
</p>
|
|
800
|
+
)}
|
|
801
|
+
</li>
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** A WORK — a component/lineage whose iterations live inside it (t24). Everything
|
|
806
|
+
* in the library is a work now: a generation RUN (keyed by its producing job id,
|
|
807
|
+
* carrying prompt+model+genKind, so Continue/Replicate apply), OR a non-gen
|
|
808
|
+
* lineage (frames / crops / uploads / demo) keyed by ORIGIN. Uniform shape — a
|
|
809
|
+
* one-iteration work renders as a section exactly like a many-iteration one. */
|
|
810
|
+
interface WorkGroup {
|
|
811
|
+
/** Stable key: the producing groupId when present, else a synthetic origin key. */
|
|
812
|
+
key: string;
|
|
813
|
+
/** True for a generation run (has groupId + genKind) — enables Continue/Replicate. */
|
|
814
|
+
isGen: boolean;
|
|
815
|
+
genKind: LibraryGenKind | null;
|
|
816
|
+
prompt: string;
|
|
817
|
+
model: string;
|
|
818
|
+
origin: string;
|
|
819
|
+
items: LibraryItem[];
|
|
820
|
+
newestAt: number;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Regroup the WHOLE library into work sections (t24 — invert the old
|
|
824
|
+
// groups-then-flat split). GROUPING KEY, strongest natural key present with NO
|
|
825
|
+
// data-shape change:
|
|
826
|
+
// • a generation run → `groupId` (the producing job id; already carried), so
|
|
827
|
+
// each run is its own work with its prompt/model.
|
|
828
|
+
// • everything else → `origin` (frames / crops / uploads / demo). Non-gen
|
|
829
|
+
// items carry no per-run id on the wire, so origin is the strongest lineage
|
|
830
|
+
// key present — all frames form the "frames" work, all uploads the "uploads"
|
|
831
|
+
// work, etc. (A per-extraction key would need a data-shape change, which this
|
|
832
|
+
// slice forbids.) Prefixed "origin:" so it can never collide with a groupId.
|
|
833
|
+
// Both levels are freshest-first: works by their newest iteration, iterations
|
|
834
|
+
// within a work newest-first too.
|
|
835
|
+
function groupLibrary(items: LibraryItem[]): WorkGroup[] {
|
|
836
|
+
const map = new Map<string, WorkGroup>();
|
|
837
|
+
for (const it of items) {
|
|
838
|
+
const isGen = !!(it.groupId && it.genKind);
|
|
839
|
+
const key = isGen ? (it.groupId as string) : `origin:${it.origin || "other"}`;
|
|
840
|
+
const g = map.get(key);
|
|
841
|
+
if (g) {
|
|
842
|
+
g.items.push(it);
|
|
843
|
+
g.newestAt = Math.max(g.newestAt, it.addedAt);
|
|
844
|
+
// A gen run keeps the provenance from its first-seen item; a non-gen work
|
|
845
|
+
// has none. (All items under a groupId share prompt/model by construction.)
|
|
846
|
+
} else {
|
|
847
|
+
map.set(key, {
|
|
848
|
+
key,
|
|
849
|
+
isGen,
|
|
850
|
+
genKind: isGen ? (it.genKind as LibraryGenKind) : null,
|
|
851
|
+
prompt: it.prompt ?? "",
|
|
852
|
+
model: it.model ?? "",
|
|
853
|
+
origin: it.origin || "other",
|
|
854
|
+
items: [it],
|
|
855
|
+
newestAt: it.addedAt,
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
const works = [...map.values()].sort((a, b) => b.newestAt - a.newestAt);
|
|
860
|
+
// Iterations newest-first within each work (both levels freshest-first).
|
|
861
|
+
for (const w of works) w.items.sort((a, b) => b.addedAt - a.addedAt);
|
|
862
|
+
return works;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// A human title for a non-gen work (the origin lineage). Gen works title
|
|
866
|
+
// themselves from prompt/model in the section header instead.
|
|
867
|
+
function originTitle(origin: string): string {
|
|
868
|
+
switch (origin) {
|
|
869
|
+
case "frames": return "Extracted frames";
|
|
870
|
+
case "upload": return "Uploads";
|
|
871
|
+
case "studio": return "Studio clips";
|
|
872
|
+
case "demo": return "Demo assets";
|
|
873
|
+
default: return origin ? origin[0].toUpperCase() + origin.slice(1) : "Other";
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// One WORK section (t24) — a collapsed-by-default row showing a representative
|
|
878
|
+
// summary (poster + title/prompt + iteration count), expandable to reveal THAT
|
|
879
|
+
// work's iterations (LibraryRow reused verbatim). Uniform for gen runs and non-gen
|
|
880
|
+
// lineages: a gen run adds the Continue/Replicate composer affordances + prompt/
|
|
881
|
+
// model header; a non-gen work (frames/uploads/…) shows its origin title. `open`
|
|
882
|
+
// is CONTROLLED by the panel (a keyed Set that survives the refresh cadence).
|
|
883
|
+
function WorkSection({
|
|
884
|
+
group,
|
|
885
|
+
open,
|
|
886
|
+
onToggle,
|
|
887
|
+
onGenerate,
|
|
888
|
+
}: {
|
|
889
|
+
group: WorkGroup;
|
|
890
|
+
open: boolean;
|
|
891
|
+
onToggle: () => void;
|
|
892
|
+
onGenerate: boolean;
|
|
893
|
+
}) {
|
|
894
|
+
const navigate = useNavigate();
|
|
895
|
+
const isScene = group.genKind === "generate_scene";
|
|
896
|
+
// A studio i2v run: its output is a produced CLIP. The Generate-composer
|
|
897
|
+
// affordances (Continue/Replicate) don't apply — the clip's chain affordance is
|
|
898
|
+
// "Send to Studio", which lives on each video row (LibraryRow) below.
|
|
899
|
+
const isStudio = group.genKind === "studio_i2v";
|
|
900
|
+
const badge = group.isGen
|
|
901
|
+
? (isStudio ? "clip" : isScene ? "scene" : "image")
|
|
902
|
+
: originTitle(group.origin).toLowerCase();
|
|
903
|
+
// The header text: a gen run shows its prompt; a non-gen work shows its origin
|
|
904
|
+
// title. Freshest-first means items[0] is the newest iteration.
|
|
905
|
+
const headerText = group.isGen
|
|
906
|
+
? group.prompt.trim() || "(no text prompt)"
|
|
907
|
+
: originTitle(group.origin);
|
|
908
|
+
// Representative poster: the FRESHEST iteration that can render a still (image
|
|
909
|
+
// or video) — items are newest-first, so the first renderable is the latest.
|
|
910
|
+
// Reuses the same <RefThumb> the expanded rows use, so collapsed poster and row
|
|
911
|
+
// still can never diverge.
|
|
912
|
+
const thumb = group.items.find(
|
|
913
|
+
(i) => i.ref.kind === "image" || i.ref.kind === "video",
|
|
914
|
+
);
|
|
915
|
+
const thumbName = thumb
|
|
916
|
+
? thumb.label || thumb.origin || thumb.ref.asset_id.slice(0, 8)
|
|
917
|
+
: "";
|
|
918
|
+
|
|
919
|
+
// Both affordances funnel through the SAME composer seam the Run path reads,
|
|
920
|
+
// then navigate so the (possibly unmounted) Generate station mounts and drains
|
|
921
|
+
// the staged request.
|
|
922
|
+
function replicate() {
|
|
923
|
+
requestStage({
|
|
924
|
+
replace: true,
|
|
925
|
+
mode: isScene ? "scene" : "image",
|
|
926
|
+
...(group.model ? { model: group.model } : {}),
|
|
927
|
+
...(group.prompt ? { text: group.prompt } : {}),
|
|
928
|
+
});
|
|
929
|
+
navigate("/generate");
|
|
930
|
+
}
|
|
931
|
+
function continueScene() {
|
|
932
|
+
// Iterations are newest-first now; the "last frame" to continue from is the
|
|
933
|
+
// FRESHEST image (items[0..]), so scan from the front for the first image.
|
|
934
|
+
const last = group.items.find((i) => i.ref.kind === "image");
|
|
935
|
+
requestStage({
|
|
936
|
+
replace: true,
|
|
937
|
+
mode: "scene",
|
|
938
|
+
...(group.model ? { model: group.model } : {}),
|
|
939
|
+
...(group.prompt ? { text: group.prompt } : {}),
|
|
940
|
+
...(last
|
|
941
|
+
? {
|
|
942
|
+
parts: [
|
|
943
|
+
{ ref: last.ref, origin: "generate", label: "continue: last frame" },
|
|
944
|
+
],
|
|
945
|
+
}
|
|
946
|
+
: {}),
|
|
947
|
+
});
|
|
948
|
+
navigate("/generate");
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
return (
|
|
952
|
+
<li className="vi-lib-group">
|
|
953
|
+
<button
|
|
954
|
+
type="button"
|
|
955
|
+
className="vi-lib-group-toggle"
|
|
956
|
+
aria-expanded={open}
|
|
957
|
+
onClick={onToggle}
|
|
958
|
+
>
|
|
959
|
+
<span className="vi-lib-group-caret" aria-hidden>
|
|
960
|
+
{open ? "▾" : "▸"}
|
|
961
|
+
</span>
|
|
962
|
+
<span className="vi-lib-badge">{badge}</span>
|
|
963
|
+
{group.isGen && (
|
|
964
|
+
<span className="vi-lib-group-model" title={`Model: ${group.model || "—"}`}>
|
|
965
|
+
{group.model || "—"}
|
|
966
|
+
</span>
|
|
967
|
+
)}
|
|
968
|
+
<span className="vi-lib-count">{group.items.length}</span>
|
|
969
|
+
</button>
|
|
970
|
+
|
|
971
|
+
{/* Poster + title/prompt read as the work's header: the representative thumb
|
|
972
|
+
sits to the LEFT. The thumb lives OUTSIDE the toggle <button> so the
|
|
973
|
+
expander's a11y/hit-target is untouched (no media nested in it). */}
|
|
974
|
+
<div className="vi-lib-group-head">
|
|
975
|
+
{thumb && (
|
|
976
|
+
<RefThumb
|
|
977
|
+
media={thumb.ref}
|
|
978
|
+
name={thumbName}
|
|
979
|
+
className="vi-lib-group-thumb"
|
|
980
|
+
/>
|
|
981
|
+
)}
|
|
982
|
+
<p className="vi-lib-group-prompt" title={headerText}>
|
|
983
|
+
{headerText}
|
|
984
|
+
</p>
|
|
985
|
+
</div>
|
|
986
|
+
|
|
987
|
+
{group.isGen && !isStudio && (
|
|
988
|
+
<div className="vi-lib-actions">
|
|
989
|
+
{isScene && (
|
|
990
|
+
<button
|
|
991
|
+
type="button"
|
|
992
|
+
className="vi-btn vi-btn-sm vi-btn-accent"
|
|
993
|
+
onClick={continueScene}
|
|
994
|
+
title="Open Generate in Scene mode, staged from this scene's last frame to extend it."
|
|
995
|
+
>
|
|
996
|
+
Continue frames
|
|
997
|
+
</button>
|
|
998
|
+
)}
|
|
999
|
+
<button
|
|
1000
|
+
type="button"
|
|
1001
|
+
className="vi-btn vi-btn-sm"
|
|
1002
|
+
onClick={replicate}
|
|
1003
|
+
title="Open Generate with this run's prompt + model staged."
|
|
1004
|
+
>
|
|
1005
|
+
Replicate prompt
|
|
1006
|
+
</button>
|
|
1007
|
+
</div>
|
|
1008
|
+
)}
|
|
1009
|
+
|
|
1010
|
+
{open && (
|
|
1011
|
+
<ul className="vi-lib-list vi-lib-group-list">
|
|
1012
|
+
{group.items.map((it) => (
|
|
1013
|
+
<LibraryRow key={it.ref.uri} it={it} onGenerate={onGenerate} />
|
|
1014
|
+
))}
|
|
1015
|
+
</ul>
|
|
1016
|
+
)}
|
|
1017
|
+
</li>
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// The Session Library tab body (t24): every entry is a WORK section — a collapsed
|
|
1022
|
+
// row you expand to see that work's iterations. No flat rows; a one-iteration work
|
|
1023
|
+
// renders as a section like any other (uniformity beats cleverness). Sections are
|
|
1024
|
+
// collapsed by default (the library shows works, not churn) and freshest-first.
|
|
1025
|
+
function LibraryPanel({
|
|
1026
|
+
items,
|
|
1027
|
+
onGenerate,
|
|
1028
|
+
}: {
|
|
1029
|
+
items: LibraryItem[];
|
|
1030
|
+
onGenerate: boolean;
|
|
1031
|
+
}) {
|
|
1032
|
+
const works = useMemo(() => groupLibrary(items), [items]);
|
|
1033
|
+
// Expand state keyed by work key — the established discipline (a Set that
|
|
1034
|
+
// survives the library's refresh cadence: the works array is rebuilt on every
|
|
1035
|
+
// change, but the keys are stable, so an expanded work stays open across a
|
|
1036
|
+
// refresh). Default posture is COLLAPSED (empty set).
|
|
1037
|
+
const [openKeys, setOpenKeys] = useState<Set<string>>(() => new Set());
|
|
1038
|
+
const toggle = (key: string) =>
|
|
1039
|
+
setOpenKeys((prev) => {
|
|
1040
|
+
const next = new Set(prev);
|
|
1041
|
+
if (next.has(key)) next.delete(key);
|
|
1042
|
+
else next.add(key);
|
|
1043
|
+
return next;
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
if (items.length === 0) {
|
|
1047
|
+
return (
|
|
1048
|
+
<p className="vi-lib-empty">
|
|
1049
|
+
No clips yet — produced crops, frames, and generations land here.
|
|
1050
|
+
</p>
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
return (
|
|
1055
|
+
<ul className="vi-lib-list">
|
|
1056
|
+
{works.map((w) => (
|
|
1057
|
+
<WorkSection
|
|
1058
|
+
key={w.key}
|
|
1059
|
+
group={w}
|
|
1060
|
+
open={openKeys.has(w.key)}
|
|
1061
|
+
onToggle={() => toggle(w.key)}
|
|
1062
|
+
onGenerate={onGenerate}
|
|
1063
|
+
/>
|
|
1064
|
+
))}
|
|
1065
|
+
</ul>
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
export function WorkbenchSidebar() {
|
|
1070
|
+
const reg = useSidebarRegistry();
|
|
1071
|
+
const library = useMediaLibrary();
|
|
1072
|
+
const records = useJobTracker();
|
|
1073
|
+
const location = useLocation();
|
|
1074
|
+
const [tab, setTab] = useState<SideTab>("processes");
|
|
1075
|
+
|
|
1076
|
+
// Compute the route gate ONCE here (not per row). Same segment convention the
|
|
1077
|
+
// shell uses: the first path segment === "generate" means the composer is live.
|
|
1078
|
+
const onGenerate =
|
|
1079
|
+
location.pathname.replace(/^\/+/, "").split("/")[0] === "generate";
|
|
1080
|
+
|
|
1081
|
+
const activeCount = useMemo(() => activeJobsOf(records).length, [records]);
|
|
1082
|
+
const libraryCount = library.length;
|
|
1083
|
+
|
|
1084
|
+
// "Clear all" wipes the WHOLE session library in one tick (unlike per-row
|
|
1085
|
+
// Remove, it has never been optimistic-with-restore, and a bulk operation has
|
|
1086
|
+
// no single row to un-hide on a partial failure) — but any STUDIO clip in that
|
|
1087
|
+
// list still needs the server-side archive, or it resurrects on the next clip
|
|
1088
|
+
// poll exactly like an un-wired single Remove would. Fire-and-forget (mirrors
|
|
1089
|
+
// jobTracker.cancelJob's idiom in this same arm): archive every studio job id
|
|
1090
|
+
// present, then clear locally immediately. A dropped archive call here just
|
|
1091
|
+
// means that one clip reappears on the next poll — recoverable via its own
|
|
1092
|
+
// Remove — never a stuck or corrupted library.
|
|
1093
|
+
function handleClearAll() {
|
|
1094
|
+
const studioJobIds = new Set(
|
|
1095
|
+
library
|
|
1096
|
+
.filter((it) => it.genKind === "studio_i2v" && it.groupId)
|
|
1097
|
+
.map((it) => it.groupId as string),
|
|
1098
|
+
);
|
|
1099
|
+
for (const jobId of studioJobIds) {
|
|
1100
|
+
void request<unknown>(studioClipArchiveUrl(jobId), {
|
|
1101
|
+
method: "POST",
|
|
1102
|
+
meta: { specKey: "studio", operation: "studio.clip.archive" },
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
clearLibrary();
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Round 9: the guarded Settings tab. If the mounted station un-registers its
|
|
1109
|
+
// settings (e.g. you navigate off Studio) while the Settings tab is showing,
|
|
1110
|
+
// fall back to Active Processes so the sidebar is never on a vanished tab.
|
|
1111
|
+
const { settingsActive } = reg;
|
|
1112
|
+
useEffect(() => {
|
|
1113
|
+
if (!settingsActive && tab === "settings") setTab("processes");
|
|
1114
|
+
}, [settingsActive, tab]);
|
|
1115
|
+
|
|
1116
|
+
return (
|
|
1117
|
+
<div className="vi-lib">
|
|
1118
|
+
{/* t52: the overlay affordances — always visible above the tab strip
|
|
1119
|
+
regardless of which sidebar tab is showing, so they survive tab
|
|
1120
|
+
switches exactly like the tabs themselves. This row is just toggles;
|
|
1121
|
+
the panels themselves expand into in-page overlays.
|
|
1122
|
+
|
|
1123
|
+
2026-08-06 (operator): "having the 3 options as tab selectable within
|
|
1124
|
+
the overlay window would be a huge convenience". Console, ComfyUI and
|
|
1125
|
+
Active now share ONE drawer window with a tab strip in its header, so
|
|
1126
|
+
switching between them is a click instead of a close-and-reopen (the
|
|
1127
|
+
scrim covers this row while a drawer is open, so these buttons could
|
|
1128
|
+
never have done it). WorkbenchDrawer renders all three toggles — they
|
|
1129
|
+
stay one-per-destination DEEP LINKS into the shared window, matching
|
|
1130
|
+
this row's idiom (and Share, which is a separate modal). Each panel's
|
|
1131
|
+
own posture is untouched: the console frame is still unsandboxed
|
|
1132
|
+
first-party, the ComfyUI frame still sandboxed behind its scheme gate,
|
|
1133
|
+
and Active still polls only while its tab is the selected one. See
|
|
1134
|
+
WorkbenchDrawer.tsx. */}
|
|
1135
|
+
<div className="vi-side-toolbar">
|
|
1136
|
+
<WorkbenchDrawer />
|
|
1137
|
+
{/* k9: the Share affordance sits beside them. It renders ONLY for a
|
|
1138
|
+
console-authenticated session (it probes an operator-gated endpoint),
|
|
1139
|
+
so a share-link visitor never sees a working Share button. */}
|
|
1140
|
+
<SharePanel />
|
|
1141
|
+
</div>
|
|
1142
|
+
|
|
1143
|
+
{/* Tab switcher — Active Processes | Session Library | (Settings). Reuses the
|
|
1144
|
+
segmented .vi-gen-mode control idiom, above the tab bodies. Settings only
|
|
1145
|
+
appears when a mounted station has registered knobs into it. */}
|
|
1146
|
+
<div className="vi-gen-mode vi-side-tabs" role="tablist" aria-label="Sidebar">
|
|
1147
|
+
<button
|
|
1148
|
+
type="button"
|
|
1149
|
+
role="tab"
|
|
1150
|
+
aria-selected={tab === "processes"}
|
|
1151
|
+
className={`vi-gen-mode-btn${
|
|
1152
|
+
tab === "processes" ? " vi-gen-mode-btn-active" : ""
|
|
1153
|
+
}`}
|
|
1154
|
+
onClick={() => setTab("processes")}
|
|
1155
|
+
>
|
|
1156
|
+
Active Processes
|
|
1157
|
+
{activeCount > 0 && <span className="vi-side-tab-badge">{activeCount}</span>}
|
|
1158
|
+
</button>
|
|
1159
|
+
<button
|
|
1160
|
+
type="button"
|
|
1161
|
+
role="tab"
|
|
1162
|
+
aria-selected={tab === "library"}
|
|
1163
|
+
className={`vi-gen-mode-btn${
|
|
1164
|
+
tab === "library" ? " vi-gen-mode-btn-active" : ""
|
|
1165
|
+
}`}
|
|
1166
|
+
onClick={() => setTab("library")}
|
|
1167
|
+
>
|
|
1168
|
+
Session Library
|
|
1169
|
+
{libraryCount > 0 && (
|
|
1170
|
+
<span className="vi-side-tab-badge">{libraryCount}</span>
|
|
1171
|
+
)}
|
|
1172
|
+
</button>
|
|
1173
|
+
{settingsActive && (
|
|
1174
|
+
<button
|
|
1175
|
+
type="button"
|
|
1176
|
+
role="tab"
|
|
1177
|
+
aria-selected={tab === "settings"}
|
|
1178
|
+
className={`vi-gen-mode-btn${
|
|
1179
|
+
tab === "settings" ? " vi-gen-mode-btn-active" : ""
|
|
1180
|
+
}`}
|
|
1181
|
+
onClick={() => setTab("settings")}
|
|
1182
|
+
>
|
|
1183
|
+
Settings
|
|
1184
|
+
</button>
|
|
1185
|
+
)}
|
|
1186
|
+
</div>
|
|
1187
|
+
|
|
1188
|
+
{tab === "processes" && (
|
|
1189
|
+
<section className="vi-proc" aria-label="Active processes">
|
|
1190
|
+
<ProcessesPanel />
|
|
1191
|
+
</section>
|
|
1192
|
+
)}
|
|
1193
|
+
{tab === "library" && (
|
|
1194
|
+
<section aria-label="Session library">
|
|
1195
|
+
<div className="vi-lib-header">
|
|
1196
|
+
<h2 className="vi-lib-title">Session library</h2>
|
|
1197
|
+
<span className="vi-lib-count">{libraryCount}</span>
|
|
1198
|
+
{libraryCount > 0 && (
|
|
1199
|
+
<button
|
|
1200
|
+
type="button"
|
|
1201
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
1202
|
+
onClick={handleClearAll}
|
|
1203
|
+
title="Forget every local entry — server media is untouched, except produced studio clips, which are archived (never deleted, reversible)."
|
|
1204
|
+
>
|
|
1205
|
+
Clear all
|
|
1206
|
+
</button>
|
|
1207
|
+
)}
|
|
1208
|
+
</div>
|
|
1209
|
+
<LibraryPanel items={library} onGenerate={onGenerate} />
|
|
1210
|
+
</section>
|
|
1211
|
+
)}
|
|
1212
|
+
{/* Settings tab body — the DOM host the mounted station portals its knobs into.
|
|
1213
|
+
Only rendered when the tab is active AND a station is registered; the knob
|
|
1214
|
+
STATE lives in the station, so unmounting this host never loses input. */}
|
|
1215
|
+
{tab === "settings" && settingsActive && (
|
|
1216
|
+
<section
|
|
1217
|
+
aria-label="Settings"
|
|
1218
|
+
className="vi-side-settings"
|
|
1219
|
+
ref={reg.setSettingsHost}
|
|
1220
|
+
/>
|
|
1221
|
+
)}
|
|
1222
|
+
</div>
|
|
1223
|
+
);
|
|
1224
|
+
}
|