@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,506 @@
|
|
|
1
|
+
// The inline LIBRARY SECTION of the single Studio plane (StudioPlane) — the bottom
|
|
2
|
+
// half of the one plane: the durable clip catalog as a list-left / player-right view
|
|
3
|
+
// with the per-row Details expander and per-clip actions, directly below the generate
|
|
4
|
+
// controls (no tabs, no navigation). (The file keeps its historical name; the export
|
|
5
|
+
// is `StudioLibrarySection`.)
|
|
6
|
+
//
|
|
7
|
+
// STATUS (confirmed 2026-07-12, still true): NOT MOUNTED anywhere — StudioPlane.tsx
|
|
8
|
+
// does not import this file. Retained per house rule (never delete, archive instead).
|
|
9
|
+
// Its row `onClick={() => c.playable && setSelected(c.job_id)}` below already IS a
|
|
10
|
+
// working select-to-viewer — but since nothing renders this component, that click
|
|
11
|
+
// handler was unreachable in BOTH plane mounts, which is part of why the viewer read as
|
|
12
|
+
// a stuck, unchangeable default. The reachable equivalent (a minimal, non-tabbed poster/
|
|
13
|
+
// id picker, same idiom) now lives on the viewer itself: StudioViewer.tsx's "▤ Play from
|
|
14
|
+
// library" panel, mounted inside the one shared StudioPlane. Re-mounting this whole
|
|
15
|
+
// section instead would reintroduce the dual-library redundancy Round 9 deliberately
|
|
16
|
+
// removed (see StudioPlane.tsx's header note) — its filters / Details / cancel / use-as-
|
|
17
|
+
// source affordances stay deferred here, for history only.
|
|
18
|
+
//
|
|
19
|
+
// Reuses, does not reinvent:
|
|
20
|
+
// • the clip list + 6s poll come from the plane (useStudioClips) — this section
|
|
21
|
+
// only RENDERS them, so the polling cadence is unchanged;
|
|
22
|
+
// • ClipDetailsPanel + the lazy per-row detail fetch are the original station's;
|
|
23
|
+
// • cancel goes through the SAME media-bus job cancel the rest of the arm uses
|
|
24
|
+
// (POST /video/jobs/<id>/cancel via config.jobCancelUrl) — a studio clip IS a
|
|
25
|
+
// media-bus job, so this is its cancel route (see report note on the brief's
|
|
26
|
+
// /llm/jobs/<id>/cancel, which is the comms store, not the media bus);
|
|
27
|
+
// • "use as source" hands the clip back to the plane, which stages it into the
|
|
28
|
+
// generate controls above (same plane) through applyStage / the studioBridge seam.
|
|
29
|
+
//
|
|
30
|
+
// Filters live in a COMPACT COLLAPSIBLE strip (not a separate tab): STATUS is fully
|
|
31
|
+
// backed by the list; CAPABILITY / MODEL need the detail endpoint (the list projection
|
|
32
|
+
// omits them — flagged in the report), so those two filter over loaded details;
|
|
33
|
+
// failed/cancelled rows auto-load their detail so their error shows inline without
|
|
34
|
+
// expanding, and "Load all details" populates the rest. The Details expander is per-row.
|
|
35
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
36
|
+
import { request, okValue, errorOf, describeAppError } from "../../transport/client";
|
|
37
|
+
import { studioClipDetailUrl, jobCancelUrl } from "../../config";
|
|
38
|
+
import {
|
|
39
|
+
ClipDetailsPanel,
|
|
40
|
+
clipDetailSchema,
|
|
41
|
+
shortId,
|
|
42
|
+
dims,
|
|
43
|
+
whenText,
|
|
44
|
+
type Clip,
|
|
45
|
+
type ClipDetail,
|
|
46
|
+
} from "./studioShared";
|
|
47
|
+
|
|
48
|
+
export interface StudioLibrarySectionProps {
|
|
49
|
+
clips: Clip[];
|
|
50
|
+
loading: boolean;
|
|
51
|
+
error: string | null;
|
|
52
|
+
reload: () => void;
|
|
53
|
+
refresh: () => void;
|
|
54
|
+
/** Stage this clip as the generate controls' source (extend) — same plane. */
|
|
55
|
+
onUseAsSource: (c: Clip) => void;
|
|
56
|
+
/** Stage this clip as a v2v scene-continuity source (restyle, holds identity). */
|
|
57
|
+
onContinueScene: (c: Clip) => void;
|
|
58
|
+
/** Selected clip id + setter — LIFTED to the plane so the player-forward viewer (in the
|
|
59
|
+
* generator column) follows the row picked here across the two-column workbench split. */
|
|
60
|
+
selected: string | null;
|
|
61
|
+
setSelected: (id: string | null) => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const CANCELLABLE = new Set(["queued", "claimed", "running"]);
|
|
65
|
+
|
|
66
|
+
function capOf(d: ClipDetail | undefined): string | undefined {
|
|
67
|
+
if (!d) return undefined;
|
|
68
|
+
const fromManifest = d.manifest?.capability;
|
|
69
|
+
if (typeof fromManifest === "string") return fromManifest;
|
|
70
|
+
const fromSpec = d.spec?.capability;
|
|
71
|
+
return typeof fromSpec === "string" ? fromSpec : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function modelOf(d: ClipDetail | undefined): string | undefined {
|
|
75
|
+
if (!d) return undefined;
|
|
76
|
+
const fromManifest = d.manifest?.model_id;
|
|
77
|
+
if (typeof fromManifest === "string") return fromManifest;
|
|
78
|
+
const fromSpec = d.spec?.model_id;
|
|
79
|
+
return typeof fromSpec === "string" ? fromSpec : undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function StudioLibrarySection({
|
|
83
|
+
clips,
|
|
84
|
+
loading,
|
|
85
|
+
error,
|
|
86
|
+
reload,
|
|
87
|
+
refresh,
|
|
88
|
+
onUseAsSource,
|
|
89
|
+
onContinueScene,
|
|
90
|
+
selected,
|
|
91
|
+
setSelected,
|
|
92
|
+
}: StudioLibrarySectionProps) {
|
|
93
|
+
const [openRows, setOpenRows] = useState<Set<string>>(new Set());
|
|
94
|
+
const [details, setDetails] = useState<Record<string, ClipDetail>>({});
|
|
95
|
+
const [detailErr, setDetailErr] = useState<Record<string, string>>({});
|
|
96
|
+
const [detailLoading, setDetailLoading] = useState<Set<string>>(new Set());
|
|
97
|
+
|
|
98
|
+
const [showFilters, setShowFilters] = useState(false);
|
|
99
|
+
const [statusFilter, setStatusFilter] = useState("all");
|
|
100
|
+
const [capFilter, setCapFilter] = useState("all");
|
|
101
|
+
const [modelFilter, setModelFilter] = useState("all");
|
|
102
|
+
const [loadingAll, setLoadingAll] = useState(false);
|
|
103
|
+
|
|
104
|
+
const [cancelBusy, setCancelBusy] = useState<Set<string>>(new Set());
|
|
105
|
+
const [copiedId, setCopiedId] = useState<string | null>(null);
|
|
106
|
+
|
|
107
|
+
const mounted = useRef(true);
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
mounted.current = true;
|
|
110
|
+
return () => {
|
|
111
|
+
mounted.current = false;
|
|
112
|
+
};
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
// Lazy per-job detail fetch (cached after — never rides the 6s list poll).
|
|
116
|
+
const fetchDetail = useCallback(
|
|
117
|
+
async (jobId: string) => {
|
|
118
|
+
if (details[jobId] || detailLoading.has(jobId)) return;
|
|
119
|
+
setDetailLoading((prev) => new Set(prev).add(jobId));
|
|
120
|
+
try {
|
|
121
|
+
const res = await request<unknown>(studioClipDetailUrl(jobId), {
|
|
122
|
+
meta: { specKey: "studio", operation: "studio.clip.detail" },
|
|
123
|
+
});
|
|
124
|
+
if (!mounted.current) return;
|
|
125
|
+
if (!res.ok) {
|
|
126
|
+
setDetailErr((p) => ({ ...p, [jobId]: describeAppError(errorOf(res)) }));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const parsed = clipDetailSchema.safeParse(okValue(res));
|
|
130
|
+
if (!parsed.success) {
|
|
131
|
+
setDetailErr((p) => ({ ...p, [jobId]: "Malformed clip detail response." }));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
setDetails((p) => ({ ...p, [jobId]: parsed.data }));
|
|
135
|
+
setDetailErr((p) => {
|
|
136
|
+
const n = { ...p };
|
|
137
|
+
delete n[jobId];
|
|
138
|
+
return n;
|
|
139
|
+
});
|
|
140
|
+
} finally {
|
|
141
|
+
if (mounted.current)
|
|
142
|
+
setDetailLoading((prev) => {
|
|
143
|
+
const n = new Set(prev);
|
|
144
|
+
n.delete(jobId);
|
|
145
|
+
return n;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
[details, detailLoading],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// (Auto-select of the newest playable clip is LIFTED to the plane, which owns the
|
|
153
|
+
// shared `selected` so the viewer in the generator column can follow it.)
|
|
154
|
+
|
|
155
|
+
// Failed/cancelled rows are the ones that most need explaining, so auto-load their
|
|
156
|
+
// detail ONCE (targeted + cheap) — the error then shows inline without expanding.
|
|
157
|
+
const autoFetched = useRef<Set<string>>(new Set());
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
for (const c of clips) {
|
|
160
|
+
const s = c.status ?? "";
|
|
161
|
+
if (
|
|
162
|
+
(s === "failed" || s === "cancelled") &&
|
|
163
|
+
!autoFetched.current.has(c.job_id) &&
|
|
164
|
+
!details[c.job_id] &&
|
|
165
|
+
!detailErr[c.job_id]
|
|
166
|
+
) {
|
|
167
|
+
autoFetched.current.add(c.job_id);
|
|
168
|
+
void fetchDetail(c.job_id);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}, [clips, details, detailErr, fetchDetail]);
|
|
172
|
+
|
|
173
|
+
const toggleDetails = useCallback(
|
|
174
|
+
(jobId: string) => {
|
|
175
|
+
let willOpen = false;
|
|
176
|
+
setOpenRows((prev) => {
|
|
177
|
+
const next = new Set(prev);
|
|
178
|
+
if (next.has(jobId)) next.delete(jobId);
|
|
179
|
+
else {
|
|
180
|
+
next.add(jobId);
|
|
181
|
+
willOpen = true;
|
|
182
|
+
}
|
|
183
|
+
return next;
|
|
184
|
+
});
|
|
185
|
+
if (willOpen) void fetchDetail(jobId);
|
|
186
|
+
},
|
|
187
|
+
[fetchDetail],
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const loadAllDetails = useCallback(async () => {
|
|
191
|
+
setLoadingAll(true);
|
|
192
|
+
try {
|
|
193
|
+
for (const c of clips) {
|
|
194
|
+
if (!details[c.job_id] && !detailErr[c.job_id]) {
|
|
195
|
+
// eslint-disable-next-line no-await-in-loop
|
|
196
|
+
await fetchDetail(c.job_id);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} finally {
|
|
200
|
+
if (mounted.current) setLoadingAll(false);
|
|
201
|
+
}
|
|
202
|
+
}, [clips, details, detailErr, fetchDetail]);
|
|
203
|
+
|
|
204
|
+
const onCancel = useCallback(
|
|
205
|
+
async (jobId: string) => {
|
|
206
|
+
setCancelBusy((prev) => new Set(prev).add(jobId));
|
|
207
|
+
try {
|
|
208
|
+
await request<unknown>(jobCancelUrl(jobId), {
|
|
209
|
+
method: "POST",
|
|
210
|
+
meta: { specKey: "studio", operation: "studio.clip.cancel" },
|
|
211
|
+
});
|
|
212
|
+
if (!mounted.current) return;
|
|
213
|
+
refresh();
|
|
214
|
+
} finally {
|
|
215
|
+
if (mounted.current)
|
|
216
|
+
setCancelBusy((prev) => {
|
|
217
|
+
const n = new Set(prev);
|
|
218
|
+
n.delete(jobId);
|
|
219
|
+
return n;
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
[refresh],
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const onCopyPath = useCallback((jobId: string, uri: string | null | undefined) => {
|
|
227
|
+
if (!uri) return;
|
|
228
|
+
try {
|
|
229
|
+
void navigator.clipboard?.writeText(uri);
|
|
230
|
+
setCopiedId(jobId);
|
|
231
|
+
window.setTimeout(() => {
|
|
232
|
+
if (mounted.current) setCopiedId((c) => (c === jobId ? null : c));
|
|
233
|
+
}, 1500);
|
|
234
|
+
} catch {
|
|
235
|
+
// clipboard unavailable — silently ignore (the path is still shown on expand)
|
|
236
|
+
}
|
|
237
|
+
}, []);
|
|
238
|
+
|
|
239
|
+
// Filter option sets. Status is from the list; capability/model from loaded
|
|
240
|
+
// details (the list projection omits them).
|
|
241
|
+
const statuses = Array.from(
|
|
242
|
+
new Set(clips.map((c) => c.status ?? "").filter(Boolean)),
|
|
243
|
+
).sort();
|
|
244
|
+
const capabilities = Array.from(
|
|
245
|
+
new Set(clips.map((c) => capOf(details[c.job_id])).filter(Boolean) as string[]),
|
|
246
|
+
).sort();
|
|
247
|
+
const models = Array.from(
|
|
248
|
+
new Set(clips.map((c) => modelOf(details[c.job_id])).filter(Boolean) as string[]),
|
|
249
|
+
).sort();
|
|
250
|
+
|
|
251
|
+
const filtered = clips.filter((c) => {
|
|
252
|
+
if (statusFilter !== "all" && (c.status ?? "") !== statusFilter) return false;
|
|
253
|
+
if (capFilter !== "all" && capOf(details[c.job_id]) !== capFilter) return false;
|
|
254
|
+
if (modelFilter !== "all" && modelOf(details[c.job_id]) !== modelFilter) return false;
|
|
255
|
+
return true;
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const filtersActive = capFilter !== "all" || modelFilter !== "all";
|
|
259
|
+
const activeFilterCount =
|
|
260
|
+
(statusFilter !== "all" ? 1 : 0) +
|
|
261
|
+
(capFilter !== "all" ? 1 : 0) +
|
|
262
|
+
(modelFilter !== "all" ? 1 : 0);
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<div className="vi-studio-library">
|
|
266
|
+
<p className="vi-comfy-label" style={{ marginBottom: "0.4rem" }}>
|
|
267
|
+
Clips
|
|
268
|
+
</p>
|
|
269
|
+
|
|
270
|
+
{/* ── compact, collapsible filters strip (not a separate tab) ── */}
|
|
271
|
+
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", alignItems: "center" }}>
|
|
272
|
+
<button
|
|
273
|
+
type="button"
|
|
274
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
275
|
+
aria-expanded={showFilters}
|
|
276
|
+
onClick={() => setShowFilters((v) => !v)}
|
|
277
|
+
>
|
|
278
|
+
{showFilters ? "▾ Filters" : "▸ Filters"}
|
|
279
|
+
{activeFilterCount > 0 ? ` (${activeFilterCount})` : ""}
|
|
280
|
+
</button>
|
|
281
|
+
<button type="button" className="vi-btn vi-btn-sm vi-btn-ghost" onClick={reload}>
|
|
282
|
+
Refresh
|
|
283
|
+
</button>
|
|
284
|
+
<span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
|
|
285
|
+
{filtered.length}/{clips.length} clips
|
|
286
|
+
</span>
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
{showFilters && (
|
|
290
|
+
<>
|
|
291
|
+
<div
|
|
292
|
+
className="vi-comfy-bar"
|
|
293
|
+
style={{ flexWrap: "wrap", gap: "0.75rem", alignItems: "flex-end", marginTop: "0.5rem" }}
|
|
294
|
+
>
|
|
295
|
+
<label className="vi-comfy-field" style={{ maxWidth: "10rem" }}>
|
|
296
|
+
<span className="vi-comfy-label">Status</span>
|
|
297
|
+
<select
|
|
298
|
+
className="vi-knob-input"
|
|
299
|
+
value={statusFilter}
|
|
300
|
+
onChange={(e) => setStatusFilter(e.target.value)}
|
|
301
|
+
>
|
|
302
|
+
<option value="all">all</option>
|
|
303
|
+
{statuses.map((s) => (
|
|
304
|
+
<option key={s} value={s}>
|
|
305
|
+
{s}
|
|
306
|
+
</option>
|
|
307
|
+
))}
|
|
308
|
+
</select>
|
|
309
|
+
</label>
|
|
310
|
+
<label className="vi-comfy-field" style={{ maxWidth: "10rem" }}>
|
|
311
|
+
<span className="vi-comfy-label">Capability</span>
|
|
312
|
+
<select
|
|
313
|
+
className="vi-knob-input"
|
|
314
|
+
value={capFilter}
|
|
315
|
+
onChange={(e) => setCapFilter(e.target.value)}
|
|
316
|
+
>
|
|
317
|
+
<option value="all">all</option>
|
|
318
|
+
{capabilities.map((c) => (
|
|
319
|
+
<option key={c} value={c}>
|
|
320
|
+
{c}
|
|
321
|
+
</option>
|
|
322
|
+
))}
|
|
323
|
+
</select>
|
|
324
|
+
</label>
|
|
325
|
+
<label className="vi-comfy-field" style={{ maxWidth: "14rem" }}>
|
|
326
|
+
<span className="vi-comfy-label">Model</span>
|
|
327
|
+
<select
|
|
328
|
+
className="vi-knob-input"
|
|
329
|
+
value={modelFilter}
|
|
330
|
+
onChange={(e) => setModelFilter(e.target.value)}
|
|
331
|
+
>
|
|
332
|
+
<option value="all">all</option>
|
|
333
|
+
{models.map((m) => (
|
|
334
|
+
<option key={m} value={m}>
|
|
335
|
+
{m}
|
|
336
|
+
</option>
|
|
337
|
+
))}
|
|
338
|
+
</select>
|
|
339
|
+
</label>
|
|
340
|
+
<div className="vi-comfy-bar-actions">
|
|
341
|
+
<button
|
|
342
|
+
type="button"
|
|
343
|
+
className="vi-btn vi-btn-ghost"
|
|
344
|
+
onClick={loadAllDetails}
|
|
345
|
+
disabled={loadingAll}
|
|
346
|
+
title="Fetch every clip's detail so the Capability / Model filters cover all rows."
|
|
347
|
+
>
|
|
348
|
+
{loadingAll ? "Loading details…" : "Load all details"}
|
|
349
|
+
</button>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
{filtersActive && (
|
|
353
|
+
<p className="vi-comfy-hint" role="note" style={{ marginTop: "0.3rem" }}>
|
|
354
|
+
Capability / Model filter over loaded details — use <strong>Load all details</strong>{" "}
|
|
355
|
+
so every clip is covered.
|
|
356
|
+
</p>
|
|
357
|
+
)}
|
|
358
|
+
</>
|
|
359
|
+
)}
|
|
360
|
+
|
|
361
|
+
{/* Clip list only — the player-forward viewer now lives in the GENERATOR column
|
|
362
|
+
(StudioViewer), driven by the lifted `selected`. This is the Library TAB body. */}
|
|
363
|
+
<ul
|
|
364
|
+
className="vi-studio-clips-list"
|
|
365
|
+
style={{
|
|
366
|
+
listStyle: "none",
|
|
367
|
+
margin: "1rem 0 0",
|
|
368
|
+
padding: 0,
|
|
369
|
+
maxHeight: "34rem",
|
|
370
|
+
overflowY: "auto",
|
|
371
|
+
}}
|
|
372
|
+
>
|
|
373
|
+
{loading && <li className="vi-comfy-hint">Loading clips…</li>}
|
|
374
|
+
{!loading && error && (
|
|
375
|
+
<li className="vi-error" role="alert">
|
|
376
|
+
{error}
|
|
377
|
+
</li>
|
|
378
|
+
)}
|
|
379
|
+
{!loading && !error && clips.length === 0 && (
|
|
380
|
+
<li className="vi-comfy-hint">No studio clips yet — generate one above.</li>
|
|
381
|
+
)}
|
|
382
|
+
{!loading && !error && clips.length > 0 && filtered.length === 0 && (
|
|
383
|
+
<li className="vi-comfy-hint">No clips match the current filters.</li>
|
|
384
|
+
)}
|
|
385
|
+
{filtered.map((c) => {
|
|
386
|
+
const isSel = c.job_id === selected;
|
|
387
|
+
const isOpen = openRows.has(c.job_id);
|
|
388
|
+
const status = c.status ?? "";
|
|
389
|
+
const isBad = status === "failed" || status === "cancelled";
|
|
390
|
+
const d = details[c.job_id];
|
|
391
|
+
const inlineErr = isBad && d?.error ? d.error : null;
|
|
392
|
+
const cancellable = CANCELLABLE.has(status);
|
|
393
|
+
return (
|
|
394
|
+
<li key={c.job_id} style={{ marginBottom: "0.5rem" }}>
|
|
395
|
+
<button
|
|
396
|
+
type="button"
|
|
397
|
+
className={isSel ? "vi-btn vi-btn-accent" : "vi-btn vi-btn-ghost"}
|
|
398
|
+
onClick={() => c.playable && setSelected(c.job_id)}
|
|
399
|
+
disabled={!c.playable}
|
|
400
|
+
title={c.job_id}
|
|
401
|
+
style={{
|
|
402
|
+
width: "100%",
|
|
403
|
+
justifyContent: "space-between",
|
|
404
|
+
display: "flex",
|
|
405
|
+
gap: "0.5rem",
|
|
406
|
+
opacity: c.playable ? 1 : 0.6,
|
|
407
|
+
}}
|
|
408
|
+
>
|
|
409
|
+
<span>{shortId(c.job_id)}</span>
|
|
410
|
+
<span style={{ fontVariantNumeric: "tabular-nums", opacity: 0.8 }}>
|
|
411
|
+
{status}
|
|
412
|
+
{dims(c) ? ` · ${dims(c)}` : ""}
|
|
413
|
+
{whenText(c) ? ` · ${whenText(c)}` : ""}
|
|
414
|
+
</span>
|
|
415
|
+
</button>
|
|
416
|
+
|
|
417
|
+
{/* Prominent inline error for a failed/cancelled row (auto-loaded). */}
|
|
418
|
+
{inlineErr && (
|
|
419
|
+
<div
|
|
420
|
+
className="vi-error"
|
|
421
|
+
role="alert"
|
|
422
|
+
style={{ margin: "0.25rem 0", fontSize: "0.82rem" }}
|
|
423
|
+
>
|
|
424
|
+
<strong>
|
|
425
|
+
{status === "cancelled" ? "Cancelled" : "Failed"} [
|
|
426
|
+
{inlineErr.code ?? "error"}]
|
|
427
|
+
</strong>
|
|
428
|
+
{inlineErr.message ? ` — ${inlineErr.message}` : ""}
|
|
429
|
+
</div>
|
|
430
|
+
)}
|
|
431
|
+
|
|
432
|
+
{/* per-clip actions */}
|
|
433
|
+
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.3rem", marginTop: "0.2rem" }}>
|
|
434
|
+
{c.playable && (
|
|
435
|
+
<button
|
|
436
|
+
type="button"
|
|
437
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
438
|
+
onClick={() => setSelected(c.job_id)}
|
|
439
|
+
>
|
|
440
|
+
Play
|
|
441
|
+
</button>
|
|
442
|
+
)}
|
|
443
|
+
{c.output?.uri && (
|
|
444
|
+
<button
|
|
445
|
+
type="button"
|
|
446
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
447
|
+
onClick={() => onCopyPath(c.job_id, c.output?.uri)}
|
|
448
|
+
>
|
|
449
|
+
{copiedId === c.job_id ? "Copied" : "Copy path"}
|
|
450
|
+
</button>
|
|
451
|
+
)}
|
|
452
|
+
{c.playable && (
|
|
453
|
+
<button
|
|
454
|
+
type="button"
|
|
455
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
456
|
+
onClick={() => onUseAsSource(c)}
|
|
457
|
+
title="Prefill the generator above with this clip as the source (extend from its last frame). If you already have reference images, the identity is carried across."
|
|
458
|
+
>
|
|
459
|
+
Use as source
|
|
460
|
+
</button>
|
|
461
|
+
)}
|
|
462
|
+
{c.playable && (
|
|
463
|
+
<button
|
|
464
|
+
type="button"
|
|
465
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
466
|
+
onClick={() => onContinueScene(c)}
|
|
467
|
+
title="Continue this scene: stage the clip as a v2v scene-continuity source (restyle / extend the prior scene while HOLDING an identity). Add or keep 1–4 reference images to lock the subject across the cut."
|
|
468
|
+
>
|
|
469
|
+
Continue scene ↦
|
|
470
|
+
</button>
|
|
471
|
+
)}
|
|
472
|
+
{cancellable && (
|
|
473
|
+
<button
|
|
474
|
+
type="button"
|
|
475
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
476
|
+
onClick={() => void onCancel(c.job_id)}
|
|
477
|
+
disabled={cancelBusy.has(c.job_id)}
|
|
478
|
+
>
|
|
479
|
+
{cancelBusy.has(c.job_id) ? "Cancelling…" : "Cancel"}
|
|
480
|
+
</button>
|
|
481
|
+
)}
|
|
482
|
+
<button
|
|
483
|
+
type="button"
|
|
484
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
485
|
+
aria-expanded={isOpen}
|
|
486
|
+
onClick={() => toggleDetails(c.job_id)}
|
|
487
|
+
style={{ opacity: 0.85 }}
|
|
488
|
+
>
|
|
489
|
+
{isOpen ? "▾ Details" : "▸ Details"}
|
|
490
|
+
</button>
|
|
491
|
+
</div>
|
|
492
|
+
|
|
493
|
+
{isOpen && (
|
|
494
|
+
<ClipDetailsPanel
|
|
495
|
+
detail={details[c.job_id]}
|
|
496
|
+
loading={detailLoading.has(c.job_id)}
|
|
497
|
+
error={detailErr[c.job_id]}
|
|
498
|
+
/>
|
|
499
|
+
)}
|
|
500
|
+
</li>
|
|
501
|
+
);
|
|
502
|
+
})}
|
|
503
|
+
</ul>
|
|
504
|
+
</div>
|
|
505
|
+
);
|
|
506
|
+
}
|