@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,821 @@
|
|
|
1
|
+
// Shared internals of the Studio WORKSPACE (the sub-tabbed promotion of the old
|
|
2
|
+
// single-page Studio Clips station). This module holds the pieces BOTH sub-tabs
|
|
3
|
+
// (Generate, Library) read, so neither tab re-declares them and the wire schemas
|
|
4
|
+
// stay in one place:
|
|
5
|
+
//
|
|
6
|
+
// • the tolerant zod schemas + types for the studio clip list and the per-clip
|
|
7
|
+
// detail (manifest / requested-spec / error), extended for the new detail
|
|
8
|
+
// `source` discriminator + bus timestamps the route now returns;
|
|
9
|
+
// • the small display helpers (shortId / dims / whenText / fmtWhen);
|
|
10
|
+
// • DetailRow + ClipDetailsPanel — the creation-parameters expander, carried
|
|
11
|
+
// verbatim from the original station and taught to LABEL a job-record view as
|
|
12
|
+
// REQUESTED (a failed/cancelled render bound no model, so its params are what
|
|
13
|
+
// was asked for, not what ran);
|
|
14
|
+
// • useStudioClips — the durable-catalog list + light background poll + the
|
|
15
|
+
// Session-Library push, extracted from the station so the shell owns it once
|
|
16
|
+
// and both tabs read the same list;
|
|
17
|
+
// • the studio constants (the VACE v2v envelope, a curated model-pin list, and
|
|
18
|
+
// the synthetic-vs-real preset test).
|
|
19
|
+
//
|
|
20
|
+
// Nothing here is new behavior — it is the original station's own code, factored
|
|
21
|
+
// so the workspace can grow two rich tabs without duplicating the contract.
|
|
22
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
import { request, okValue, errorOf, describeAppError } from "../../transport/client";
|
|
25
|
+
import { hugpyConfig, studioClipDetailUrl, mediaBytesUrl } from "../../config";
|
|
26
|
+
import { addToLibrary, updateLibraryProvenance } from "../../video/mediaLibrary";
|
|
27
|
+
import type { LibraryItem } from "../../video/mediaLibrary";
|
|
28
|
+
import type { MediaRef } from "../../video/contract";
|
|
29
|
+
import type { StudioPreset } from "../../video/useStudioPresets";
|
|
30
|
+
|
|
31
|
+
// ── studio constants ───────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
// v2v RESTYLE — the ONLY geometry the Wan 2.1 VACE 1.3B control model accepts:
|
|
34
|
+
// 832x480 LANDSCAPE @16fps at a 6 GB budget. The studio router rejects a
|
|
35
|
+
// portrait/oversized v2v request (RESOLUTION_UNSUPPORTED), so restyle mode LOCKS
|
|
36
|
+
// the form to this envelope; nothing dead-on-arrival can be enqueued.
|
|
37
|
+
export const VACE_W = 832;
|
|
38
|
+
export const VACE_H = 480;
|
|
39
|
+
export const VACE_FPS = 16;
|
|
40
|
+
export const VACE_BUDGET_GB = 6;
|
|
41
|
+
|
|
42
|
+
// ── honesty banner (operator ask 2026-07-12): "leaving VRAM budget blank silently
|
|
43
|
+
// binds the SYNTHETIC tier" bit the operator repeatedly — the only prior hint was
|
|
44
|
+
// a footnote nobody reads. These three pieces are the shared basis for a LOUD
|
|
45
|
+
// pre-submit banner in both Studio surfaces (StudioGenerateTab's Clip mode,
|
|
46
|
+
// StudioMovieComposer's Movie mode).
|
|
47
|
+
//
|
|
48
|
+
// ⚠ THE 0.5 PREDICTION IS GONE (operator ruling 2026-07-27). A blank budget used to
|
|
49
|
+
// mean "server substitutes 0.5", which sat under every real model's floor and bound the
|
|
50
|
+
// synthetic prover — so this file predicted 0.5 to raise the banner. Blank now means
|
|
51
|
+
// AUTOFIT TO THE CARD'S CAPACITY (the reservation engine evicts to free that room), and
|
|
52
|
+
// an unmeasurable worker REFUSES rather than falling back to a number. Predicting 0.5
|
|
53
|
+
// today would warn on every blank submit, which is the wrong lesson to teach.
|
|
54
|
+
|
|
55
|
+
// STUDIO_REAL_FLOOR_GB: the cheapest REAL model footprint among the capabilities
|
|
56
|
+
// that ALSO have a synthetic last-resort registered (t2v, i2v — see
|
|
57
|
+
// studio/models_seed.py: synthetic-t2v/synthetic-i2v are the only synthetic
|
|
58
|
+
// ModelConfigs, and the router's real_first score dimension means a real model
|
|
59
|
+
// ALWAYS wins when one fits — synthetic binds only when NO real model fits the
|
|
60
|
+
// budget). Reuses VACE_BUDGET_GB's number on purpose: wan2.1-vace-1.3b's 6GB INT8
|
|
61
|
+
// envelope is the cheapest real footprint in the whole catalog, so 6 is a single
|
|
62
|
+
// honest floor to advise regardless of which of the two synthetic-shadowed
|
|
63
|
+
// capabilities is active. v2v / id_lock are NOT in this club — models_seed.py
|
|
64
|
+
// registers no synthetic model for either capability, so a too-low budget there
|
|
65
|
+
// fails the router honestly (NO_CAPABLE_MODEL/VRAM_EXCEEDED) instead of silently
|
|
66
|
+
// rendering noise; the honesty banner has nothing to warn about on those shapes.
|
|
67
|
+
export const STUDIO_REAL_FLOOR_GB = VACE_BUDGET_GB;
|
|
68
|
+
|
|
69
|
+
// The banner's one-click fill target. 10 clears STUDIO_REAL_FLOOR_GB with headroom
|
|
70
|
+
// for either shadowed capability's cheapest real model (t2v ~5GB, i2v ~8GB via
|
|
71
|
+
// ltx-video-0.9.7-dev INT8) without hand-tuning per capability.
|
|
72
|
+
export const STUDIO_SAFE_FILL_GB = 10;
|
|
73
|
+
|
|
74
|
+
/** The budget the ROUTE would effectively see, or `null` when the operator has TYPED
|
|
75
|
+
* NOTHING USABLE — blank or garbled, both of which the server now resolves by sizing
|
|
76
|
+
* to the worker's GPU capacity (or refusing outright if it cannot read it).
|
|
77
|
+
*
|
|
78
|
+
* `null` means "autofit — not predictable from here", NOT "zero". Callers must test it
|
|
79
|
+
* explicitly: `n < FLOOR` would be TRUE for null (null coerces to 0 in JS), which would
|
|
80
|
+
* raise the synthetic warning on every blank field — exactly the false alarm this
|
|
81
|
+
* return type exists to prevent. */
|
|
82
|
+
export function effectiveBudgetGb(raw: string): number | null {
|
|
83
|
+
const t = raw.trim();
|
|
84
|
+
if (t === "") return null;
|
|
85
|
+
const n = Number(t);
|
|
86
|
+
return Number.isFinite(n) ? n : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Would this budget bind the synthetic prover? Only a TYPED number under the real
|
|
90
|
+
* floor can; a blank field autofits to capacity server-side and is not a warning.
|
|
91
|
+
*
|
|
92
|
+
* ⚠ `floorGb` (2026-07-29): the caller passes the MEASURED floor for the active
|
|
93
|
+
* capability — `vram_envelope_gb` off GET /video/render/presets, which is the exact
|
|
94
|
+
* number the router compares a budget against (`cfg.vram.fits(budget)` is
|
|
95
|
+
* `gb <= budget`). STUDIO_REAL_FLOOR_GB stays only as the fallback for a page whose
|
|
96
|
+
* discovery GET has not landed, and it is a KNOWN-LOW fallback: it says 6 while the
|
|
97
|
+
* cheapest real row in the ratified table is 8.2, so a budget of 7 binds synthetic and
|
|
98
|
+
* the un-parameterised form of this predicate said nothing. Deriving beats mirroring. */
|
|
99
|
+
export function bindsSyntheticTier(raw: string, floorGb: number = STUDIO_REAL_FLOOR_GB): boolean {
|
|
100
|
+
const n = effectiveBudgetGb(raw);
|
|
101
|
+
return n !== null && n < floorGb;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// A studio job does NOT pin a model by default — the capability router picks one
|
|
105
|
+
// from capability + budget + resolution. This is a SENSIBLE, curated list of the
|
|
106
|
+
// studio model ids an operator might PIN (from video_intel/studio/models_seed.py:
|
|
107
|
+
// the Wan tiers + the synthetic provers), offered blank-first ("auto"). It is
|
|
108
|
+
// advisory: an unroutable pin comes back as errors-as-data on the job, never a
|
|
109
|
+
// silent fallback.
|
|
110
|
+
//
|
|
111
|
+
// ⚠ THIS LIST IS NOW THE FALLBACK / ESCAPE HATCH, NOT THE MENU (2026-07-29). It is a
|
|
112
|
+
// hand-maintained mirror of a backend seed file and it is unfiltered: nothing in it
|
|
113
|
+
// knows which of these models can serve the capability the operator has chosen, so
|
|
114
|
+
// pinning wan2.1-t2v-1.3b under capability=v2v was offered as a first-class choice and
|
|
115
|
+
// only failed at render time. The pickers now build their menu from
|
|
116
|
+
// `modelsForCapability` over the MEASURED render-preset table and fall back to this list
|
|
117
|
+
// only while that discovery GET has not landed — plus keep it behind an explicit
|
|
118
|
+
// "show all models" toggle so an operator can still pin off-menu, labelled unverified.
|
|
119
|
+
export const STUDIO_MODEL_IDS: readonly string[] = [
|
|
120
|
+
"wan2.1-t2v-1.3b",
|
|
121
|
+
"wan2.1-i2v-14b-720p",
|
|
122
|
+
// wan2.2-{t2v,i2v}-a14b removed 2026-08-13: battery-measured 22.2GiB peak
|
|
123
|
+
// cannot complete on the fleet 24GB card; registry rows dropped too
|
|
124
|
+
// (models_seed.py tombstone). Weights kept on the shared store for a
|
|
125
|
+
// bigger-card future.
|
|
126
|
+
"wan2.1-vace-1.3b",
|
|
127
|
+
"wan2.1-vace-14b",
|
|
128
|
+
"synthetic-i2v",
|
|
129
|
+
"synthetic-t2v",
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
// A preset is a SYNTHETIC preview iff it ships the honesty badge (prompt_note) —
|
|
133
|
+
// the seed table sets it ONLY on the no-GPU procedural provers, whose frames are a
|
|
134
|
+
// pure function of seed + geometry (the prompt is recorded, not rendered). Real
|
|
135
|
+
// tiers leave it empty. This is the discriminator the Generate tab groups by.
|
|
136
|
+
export function isSyntheticPreset(p: StudioPreset): boolean {
|
|
137
|
+
return typeof p.prompt_note === "string" && p.prompt_note.trim().length > 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── clip list schemas (tolerant/passthrough — assert only what we read) ──────
|
|
141
|
+
|
|
142
|
+
const clipOutputSchema = z
|
|
143
|
+
.object({
|
|
144
|
+
asset_id: z.string().nullable().optional(),
|
|
145
|
+
uri: z.string().nullable().optional(),
|
|
146
|
+
mime: z.string().nullable().optional(),
|
|
147
|
+
width: z.number().nullable().optional(),
|
|
148
|
+
height: z.number().nullable().optional(),
|
|
149
|
+
duration_s: z.number().nullable().optional(),
|
|
150
|
+
})
|
|
151
|
+
.passthrough();
|
|
152
|
+
|
|
153
|
+
// Placement (Active Processes) — WHERE a clip's render physically executes.
|
|
154
|
+
// Additive + omit-when-unset on the wire; tolerant/passthrough like the rest.
|
|
155
|
+
const placementSchema = z
|
|
156
|
+
.object({
|
|
157
|
+
source: z.string().nullable().optional(),
|
|
158
|
+
host: z.string().nullable().optional(),
|
|
159
|
+
worker_id: z.string().nullable().optional(),
|
|
160
|
+
gpu: z.string().nullable().optional(),
|
|
161
|
+
process: z.string().nullable().optional(),
|
|
162
|
+
reserved_bytes: z.number().nullable().optional(),
|
|
163
|
+
})
|
|
164
|
+
.passthrough();
|
|
165
|
+
|
|
166
|
+
// STAGE TIMELINE (the exhaustive per-process telemetry). One coarse-stage entry a
|
|
167
|
+
// render moved through — deduped server-side (a frame loop is one row with a running
|
|
168
|
+
// `count`). The terminal row also carries the outcome (code/message/retryable +
|
|
169
|
+
// failed_at_stage). RETAINED through terminal, so a failed row shows its full history.
|
|
170
|
+
const stageEntrySchema = z
|
|
171
|
+
.object({
|
|
172
|
+
stage: z.string(),
|
|
173
|
+
ts: z.number().nullable().optional(),
|
|
174
|
+
ts_last: z.number().nullable().optional(),
|
|
175
|
+
count: z.number().nullable().optional(),
|
|
176
|
+
detail: z.string().nullable().optional(),
|
|
177
|
+
// terminal-entry extras
|
|
178
|
+
code: z.string().nullable().optional(),
|
|
179
|
+
message: z.string().nullable().optional(),
|
|
180
|
+
retryable: z.boolean().nullable().optional(),
|
|
181
|
+
failed_at_stage: z.string().nullable().optional(),
|
|
182
|
+
})
|
|
183
|
+
.passthrough();
|
|
184
|
+
export type StageEntry = z.infer<typeof stageEntrySchema>;
|
|
185
|
+
|
|
186
|
+
// Terminal FAILURE summary — the exact "where it's failing": the failing STAGE plus
|
|
187
|
+
// the error code/message/retryable. Present only on a failed/cancelled row.
|
|
188
|
+
const failureSchema = z
|
|
189
|
+
.object({
|
|
190
|
+
stage: z.string().nullable().optional(),
|
|
191
|
+
code: z.string().nullable().optional(),
|
|
192
|
+
message: z.string().nullable().optional(),
|
|
193
|
+
retryable: z.boolean().nullable().optional(),
|
|
194
|
+
})
|
|
195
|
+
.passthrough();
|
|
196
|
+
export type JobFailure = z.infer<typeof failureSchema>;
|
|
197
|
+
|
|
198
|
+
export const clipSchema = z
|
|
199
|
+
.object({
|
|
200
|
+
job_id: z.string(),
|
|
201
|
+
status: z.string().nullable().optional(),
|
|
202
|
+
playable: z.boolean().nullable().optional(),
|
|
203
|
+
created: z.number().nullable().optional(),
|
|
204
|
+
updated: z.number().nullable().optional(),
|
|
205
|
+
output: clipOutputSchema.nullable().optional(),
|
|
206
|
+
// Live progress blob (carries the awaiting_capacity HOLD marker) + placement —
|
|
207
|
+
// both additive; present only when the server sends them.
|
|
208
|
+
progress: z.record(z.unknown()).nullable().optional(),
|
|
209
|
+
placement: placementSchema.nullable().optional(),
|
|
210
|
+
// Exhaustive per-process telemetry (all additive; retained through terminal):
|
|
211
|
+
// the stage TIMELINE, the terminal FAILURE summary, the honest last-movement ts
|
|
212
|
+
// (stall basis), and the current stage.
|
|
213
|
+
stage_log: z.array(stageEntrySchema).nullable().optional(),
|
|
214
|
+
failure: failureSchema.nullable().optional(),
|
|
215
|
+
last_movement_ts: z.number().nullable().optional(),
|
|
216
|
+
current_stage: z.string().nullable().optional(),
|
|
217
|
+
})
|
|
218
|
+
.passthrough();
|
|
219
|
+
|
|
220
|
+
export const clipsResponseSchema = z.object({ clips: z.array(clipSchema) }).passthrough();
|
|
221
|
+
|
|
222
|
+
export type Clip = z.infer<typeof clipSchema>;
|
|
223
|
+
|
|
224
|
+
/** Build a durable video MediaRef from a studio clip row, or null if it carries no
|
|
225
|
+
* streamable output (uri + asset_id). Used by the source-clip picker + "use as
|
|
226
|
+
* source" so both derive the same ref the enqueue body wants. */
|
|
227
|
+
export function clipToMediaRef(c: Clip): MediaRef | null {
|
|
228
|
+
const o = c.output;
|
|
229
|
+
if (!o || !o.uri || !o.asset_id) return null;
|
|
230
|
+
return {
|
|
231
|
+
asset_id: o.asset_id,
|
|
232
|
+
kind: "video",
|
|
233
|
+
uri: o.uri,
|
|
234
|
+
mime: o.mime ?? "video/mp4",
|
|
235
|
+
width: o.width ?? null,
|
|
236
|
+
height: o.height ?? null,
|
|
237
|
+
duration_s: o.duration_s ?? null,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// GET /video/studio/clip/<id>/detail — the exact creation params for a row expander.
|
|
242
|
+
// `source` (new): "manifest" = the resolved params from the content-addressed
|
|
243
|
+
// manifest beside a produced clip; "job_record" = a failed/cancelled/running job
|
|
244
|
+
// wrote no manifest, so `spec` is the REQUESTED params and `error` the failure.
|
|
245
|
+
export const clipDetailSchema = z
|
|
246
|
+
.object({
|
|
247
|
+
job_id: z.string(),
|
|
248
|
+
status: z.string().nullable().optional(),
|
|
249
|
+
source: z.string().nullable().optional(),
|
|
250
|
+
spec: z.record(z.unknown()).nullable().optional(),
|
|
251
|
+
manifest: z
|
|
252
|
+
.object({
|
|
253
|
+
content_hash: z.string().nullable().optional(),
|
|
254
|
+
model_id: z.string().nullable().optional(),
|
|
255
|
+
precision: z.string().nullable().optional(),
|
|
256
|
+
resolution: z
|
|
257
|
+
.object({ width: z.number(), height: z.number(), fps: z.number() })
|
|
258
|
+
.nullable()
|
|
259
|
+
.optional(),
|
|
260
|
+
duration_s: z.number().nullable().optional(),
|
|
261
|
+
frames: z.number().nullable().optional(),
|
|
262
|
+
seed: z.number().nullable().optional(),
|
|
263
|
+
sampler: z.record(z.unknown()).nullable().optional(),
|
|
264
|
+
prompt: z.string().nullable().optional(),
|
|
265
|
+
negative_prompt: z.string().nullable().optional(),
|
|
266
|
+
source_video: z.string().nullable().optional(),
|
|
267
|
+
})
|
|
268
|
+
.passthrough()
|
|
269
|
+
.nullable()
|
|
270
|
+
.optional(),
|
|
271
|
+
error: z
|
|
272
|
+
.object({
|
|
273
|
+
code: z.string().nullable().optional(),
|
|
274
|
+
message: z.string().nullable().optional(),
|
|
275
|
+
retryable: z.boolean().nullable().optional(),
|
|
276
|
+
// the failing STAGE (from the retained timeline) — where it broke.
|
|
277
|
+
stage: z.string().nullable().optional(),
|
|
278
|
+
})
|
|
279
|
+
.passthrough()
|
|
280
|
+
.nullable()
|
|
281
|
+
.optional(),
|
|
282
|
+
// Retained stage TIMELINE + current stage + last-movement ts (additive) so a
|
|
283
|
+
// terminal Library row is inspectable with exactly what it did + where it stopped.
|
|
284
|
+
stage_log: z.array(stageEntrySchema).nullable().optional(),
|
|
285
|
+
current_stage: z.string().nullable().optional(),
|
|
286
|
+
last_movement_ts: z.number().nullable().optional(),
|
|
287
|
+
created: z.number().nullable().optional(),
|
|
288
|
+
updated: z.number().nullable().optional(),
|
|
289
|
+
})
|
|
290
|
+
.passthrough();
|
|
291
|
+
export type ClipDetail = z.infer<typeof clipDetailSchema>;
|
|
292
|
+
|
|
293
|
+
// ── display helpers ──────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
export function shortId(id: string): string {
|
|
296
|
+
return id.length > 10 ? `${id.slice(0, 8)}…` : id;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function dims(c: Clip): string {
|
|
300
|
+
const o = c.output;
|
|
301
|
+
if (!o || o.width == null || o.height == null) return "";
|
|
302
|
+
const d = o.duration_s != null ? ` · ${o.duration_s.toFixed(1)}s` : "";
|
|
303
|
+
return `${o.width}×${o.height}${d}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function whenText(c: Clip): string {
|
|
307
|
+
const t = c.updated ?? c.created;
|
|
308
|
+
if (t == null) return "";
|
|
309
|
+
try {
|
|
310
|
+
return new Date(t * 1000).toLocaleTimeString();
|
|
311
|
+
} catch {
|
|
312
|
+
return "";
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Absolute local date+time for a bus epoch-seconds timestamp (detail expander). */
|
|
317
|
+
export function fmtWhen(t: number | null | undefined): string {
|
|
318
|
+
if (t == null) return "";
|
|
319
|
+
try {
|
|
320
|
+
return new Date(t * 1000).toLocaleString();
|
|
321
|
+
} catch {
|
|
322
|
+
return "";
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── the STAGE TIMELINE view (the exhaustive per-process expandable) ──────────
|
|
327
|
+
|
|
328
|
+
const TIMELINE_TERMINAL = new Set(["done", "failed", "cancelled"]);
|
|
329
|
+
// A render is "stalled" when the current stage has not moved for this long. Honest,
|
|
330
|
+
// tied to the CURRENT stage (server-computed last_movement_ts), not a bare row clock.
|
|
331
|
+
export const PROC_STALL_MS = 90_000;
|
|
332
|
+
|
|
333
|
+
/** "12s" / "3m 4s" / "1h 2m" since an epoch-seconds instant. */
|
|
334
|
+
export function agoText(tsSec: number | null | undefined, nowMs: number): string {
|
|
335
|
+
if (tsSec == null) return "";
|
|
336
|
+
const secs = Math.max(0, Math.floor(nowMs / 1000 - tsSec));
|
|
337
|
+
if (secs < 60) return `${secs}s`;
|
|
338
|
+
const m = Math.floor(secs / 60);
|
|
339
|
+
if (m < 60) return `${m}m ${secs % 60}s`;
|
|
340
|
+
return `${Math.floor(m / 60)}h ${m % 60}m`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Is a job stalled? current stage silent past the window (never for a terminal job). */
|
|
344
|
+
export function isStalled(
|
|
345
|
+
lastMovementTs: number | null | undefined,
|
|
346
|
+
terminal: boolean,
|
|
347
|
+
nowMs: number,
|
|
348
|
+
): boolean {
|
|
349
|
+
if (terminal || lastMovementTs == null) return false;
|
|
350
|
+
return nowMs / 1000 - lastMovementTs > PROC_STALL_MS / 1000;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The exhaustive per-process detail: the STAGE TIMELINE (each stage with its time +
|
|
355
|
+
* detail; the current stage highlighted, done stages checked), the CURRENT ACTIVITY
|
|
356
|
+
* line, an honest STALL indicator tied to the current stage, and — the whole point —
|
|
357
|
+
* on failure the exact failing STAGE + error code + message, prominently.
|
|
358
|
+
*
|
|
359
|
+
* No fabricated bars: this renders only what the server sent. `nowMs` is threaded in
|
|
360
|
+
* (a ~1s tick from the parent) so the stall clock advances live without a poll.
|
|
361
|
+
*/
|
|
362
|
+
export function ProcessTimeline({
|
|
363
|
+
stageLog,
|
|
364
|
+
failure,
|
|
365
|
+
currentStage,
|
|
366
|
+
lastMovementTs,
|
|
367
|
+
terminal = false,
|
|
368
|
+
nowMs,
|
|
369
|
+
}: {
|
|
370
|
+
stageLog: StageEntry[] | null | undefined;
|
|
371
|
+
failure?: JobFailure | null;
|
|
372
|
+
currentStage?: string | null;
|
|
373
|
+
lastMovementTs?: number | null;
|
|
374
|
+
terminal?: boolean;
|
|
375
|
+
nowMs: number;
|
|
376
|
+
}) {
|
|
377
|
+
const log = stageLog ?? [];
|
|
378
|
+
const stalled = isStalled(lastMovementTs, terminal, nowMs);
|
|
379
|
+
|
|
380
|
+
// The current stage's freshest detail = the "what it's doing right now" line.
|
|
381
|
+
const currentEntry =
|
|
382
|
+
!terminal && currentStage
|
|
383
|
+
? [...log].reverse().find((e) => e.stage === currentStage)
|
|
384
|
+
: undefined;
|
|
385
|
+
const currentDetail = currentEntry?.detail;
|
|
386
|
+
|
|
387
|
+
if (log.length === 0 && !failure) {
|
|
388
|
+
return (
|
|
389
|
+
<p className="vi-timeline-empty">
|
|
390
|
+
No stage history recorded yet — it appears here as the render moves through
|
|
391
|
+
its stages.
|
|
392
|
+
</p>
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return (
|
|
397
|
+
<div className="vi-timeline">
|
|
398
|
+
{/* FAILURE — the exact where-it-broke, first + loud. */}
|
|
399
|
+
{failure && failure.code && (
|
|
400
|
+
<div className="vi-timeline-failure" role="alert">
|
|
401
|
+
<strong>
|
|
402
|
+
Failed{failure.stage ? ` at ${failure.stage}` : ""} [{failure.code}]
|
|
403
|
+
</strong>
|
|
404
|
+
{failure.retryable ? <span className="vi-timeline-retry"> · retryable</span> : null}
|
|
405
|
+
{failure.message ? <div className="vi-timeline-failmsg">{failure.message}</div> : null}
|
|
406
|
+
</div>
|
|
407
|
+
)}
|
|
408
|
+
|
|
409
|
+
{/* CURRENT ACTIVITY — the live "frame 12/48" / "awaiting capacity …" line. */}
|
|
410
|
+
{!terminal && (currentDetail || currentStage) && (
|
|
411
|
+
<div className={`vi-timeline-current${stalled ? " vi-timeline-current-stalled" : ""}`}>
|
|
412
|
+
<span className="vi-timeline-current-label">now</span>
|
|
413
|
+
<span className="vi-timeline-current-text">
|
|
414
|
+
{currentDetail || currentStage}
|
|
415
|
+
</span>
|
|
416
|
+
{stalled && lastMovementTs != null ? (
|
|
417
|
+
<span className="vi-timeline-stall" title="No stage movement in a while">
|
|
418
|
+
⚠ no movement {agoText(lastMovementTs, nowMs)}
|
|
419
|
+
</span>
|
|
420
|
+
) : lastMovementTs != null ? (
|
|
421
|
+
<span className="vi-timeline-move">· {agoText(lastMovementTs, nowMs)} since last movement</span>
|
|
422
|
+
) : null}
|
|
423
|
+
</div>
|
|
424
|
+
)}
|
|
425
|
+
|
|
426
|
+
{/* THE TIMELINE — every stage the render moved through, in order. */}
|
|
427
|
+
<ol className="vi-timeline-list">
|
|
428
|
+
{log.map((e, i) => {
|
|
429
|
+
const isTerm = TIMELINE_TERMINAL.has(e.stage);
|
|
430
|
+
const isCurrent = !terminal && !isTerm && e.stage === currentStage && i === log.length - 1;
|
|
431
|
+
const failed = e.stage === "failed" || e.stage === "cancelled";
|
|
432
|
+
const marker = failed ? "✕" : isTerm ? "✓" : isCurrent ? "●" : "✓";
|
|
433
|
+
const cls = failed
|
|
434
|
+
? "vi-timeline-mark-fail"
|
|
435
|
+
: isCurrent
|
|
436
|
+
? `vi-timeline-mark-current${stalled ? " vi-timeline-mark-stalled" : ""}`
|
|
437
|
+
: "vi-timeline-mark-done";
|
|
438
|
+
return (
|
|
439
|
+
<li key={`${e.stage}-${i}`} className="vi-timeline-item">
|
|
440
|
+
<span className={`vi-timeline-mark ${cls}`} aria-hidden>
|
|
441
|
+
{marker}
|
|
442
|
+
</span>
|
|
443
|
+
<span className="vi-timeline-body">
|
|
444
|
+
<span className="vi-timeline-stage">
|
|
445
|
+
{e.stage.replace(/_/g, " ")}
|
|
446
|
+
{e.count && e.count > 1 ? (
|
|
447
|
+
<span className="vi-timeline-count"> ×{e.count}</span>
|
|
448
|
+
) : null}
|
|
449
|
+
</span>
|
|
450
|
+
{e.detail ? <span className="vi-timeline-detail">{e.detail}</span> : null}
|
|
451
|
+
</span>
|
|
452
|
+
{e.ts != null ? (
|
|
453
|
+
<span className="vi-timeline-time" title={fmtWhen(e.ts_last ?? e.ts)}>
|
|
454
|
+
{agoText(e.ts_last ?? e.ts, nowMs)}
|
|
455
|
+
</span>
|
|
456
|
+
) : null}
|
|
457
|
+
</li>
|
|
458
|
+
);
|
|
459
|
+
})}
|
|
460
|
+
</ol>
|
|
461
|
+
</div>
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ── shared picker widgets ────────────────────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
// A small library-thumbnail grid; `onPick` receives the chosen ref. Extracted
|
|
468
|
+
// MINIMALLY (operator ask 2026-07-12) out of the Clip surface's original inline
|
|
469
|
+
// `libraryGrid` closure — same markup, same empty-state copy, same keyed-intrinsic-
|
|
470
|
+
// <button> constraint (the repo's JSX typing rejects a `key` on a custom component,
|
|
471
|
+
// so each row stays a plain <button>) — so every studio image-library picker (the
|
|
472
|
+
// Clip surface's start/reference/control slots AND the Movie composer's start-image
|
|
473
|
+
// switcher) shares ONE implementation instead of three copies. `images` is the
|
|
474
|
+
// CALLER's already-kind-filtered list (each surface knows whether it wants image vs
|
|
475
|
+
// video items from its own useMediaLibrary() read).
|
|
476
|
+
export function LibraryImageGrid({
|
|
477
|
+
images,
|
|
478
|
+
onPick,
|
|
479
|
+
}: {
|
|
480
|
+
images: LibraryItem[];
|
|
481
|
+
onPick: (ref: MediaRef) => void;
|
|
482
|
+
}) {
|
|
483
|
+
return (
|
|
484
|
+
<div
|
|
485
|
+
style={{
|
|
486
|
+
display: "flex",
|
|
487
|
+
flexWrap: "wrap",
|
|
488
|
+
gap: "0.4rem",
|
|
489
|
+
marginTop: "0.5rem",
|
|
490
|
+
maxHeight: "12rem",
|
|
491
|
+
overflowY: "auto",
|
|
492
|
+
}}
|
|
493
|
+
>
|
|
494
|
+
{images.length === 0 ? (
|
|
495
|
+
<p className="vi-comfy-hint">
|
|
496
|
+
No images in the session library yet — upload one, or produce frames in the Frames tab.
|
|
497
|
+
</p>
|
|
498
|
+
) : (
|
|
499
|
+
images.map((it) => (
|
|
500
|
+
<button
|
|
501
|
+
key={it.ref.uri}
|
|
502
|
+
type="button"
|
|
503
|
+
className="vi-btn vi-btn-ghost"
|
|
504
|
+
onClick={() => onPick(it.ref)}
|
|
505
|
+
title={it.label ?? it.ref.asset_id}
|
|
506
|
+
style={{ padding: 2, border: "none" }}
|
|
507
|
+
>
|
|
508
|
+
<img
|
|
509
|
+
src={mediaBytesUrl(it.ref.uri)}
|
|
510
|
+
alt={it.label ?? "library image"}
|
|
511
|
+
style={{
|
|
512
|
+
width: "4.5rem",
|
|
513
|
+
height: "4.5rem",
|
|
514
|
+
objectFit: "cover",
|
|
515
|
+
borderRadius: "0.35rem",
|
|
516
|
+
background: "#000",
|
|
517
|
+
}}
|
|
518
|
+
/>
|
|
519
|
+
</button>
|
|
520
|
+
))
|
|
521
|
+
)}
|
|
522
|
+
</div>
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ── the creation-parameters expander (carried from the original station) ─────
|
|
527
|
+
|
|
528
|
+
// One label:value row. Skips null/empty so a compact panel only shows params that
|
|
529
|
+
// actually exist.
|
|
530
|
+
export function DetailRow({ label, value }: { label: string; value: unknown }) {
|
|
531
|
+
if (value == null || value === "") return null;
|
|
532
|
+
return (
|
|
533
|
+
<div style={{ display: "flex", gap: "0.5rem", lineHeight: 1.5 }}>
|
|
534
|
+
<span style={{ opacity: 0.7, minWidth: "7rem" }}>{label}</span>
|
|
535
|
+
<span style={{ fontVariantNumeric: "tabular-nums", wordBreak: "break-word" }}>
|
|
536
|
+
{String(value)}
|
|
537
|
+
</span>
|
|
538
|
+
</div>
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Collapsible creation-parameters panel for one clip row. Shows the TRUE rendered
|
|
543
|
+
// params from the manifest (done) and, for a job with NO manifest (failed /
|
|
544
|
+
// cancelled / still running), the error prominently + the REQUESTED spec — clearly
|
|
545
|
+
// labeled as requested (not resolved), because that render never bound a model.
|
|
546
|
+
export function ClipDetailsPanel({
|
|
547
|
+
detail,
|
|
548
|
+
loading,
|
|
549
|
+
error,
|
|
550
|
+
}: {
|
|
551
|
+
detail: ClipDetail | undefined;
|
|
552
|
+
loading: boolean;
|
|
553
|
+
error: string | undefined;
|
|
554
|
+
}) {
|
|
555
|
+
if (loading && !detail) return <p className="vi-comfy-hint">Loading details…</p>;
|
|
556
|
+
if (error)
|
|
557
|
+
return (
|
|
558
|
+
<p className="vi-error" role="alert">
|
|
559
|
+
{error}
|
|
560
|
+
</p>
|
|
561
|
+
);
|
|
562
|
+
if (!detail) return null;
|
|
563
|
+
const m = detail.manifest;
|
|
564
|
+
const s = (detail.spec ?? {}) as Record<string, unknown>;
|
|
565
|
+
const sampler = (m?.sampler ?? {}) as Record<string, unknown>;
|
|
566
|
+
const res = m?.resolution;
|
|
567
|
+
// "job_record" (or simply no manifest) = the params below are REQUESTED, not
|
|
568
|
+
// resolved. Label them so an operator never reads a failed row's asked-for
|
|
569
|
+
// numbers as what actually ran.
|
|
570
|
+
const requested = !m || detail.source === "job_record";
|
|
571
|
+
return (
|
|
572
|
+
<div
|
|
573
|
+
style={{
|
|
574
|
+
marginTop: "0.4rem",
|
|
575
|
+
padding: "0.5rem 0.6rem",
|
|
576
|
+
borderRadius: "0.4rem",
|
|
577
|
+
background: "rgba(127,127,127,0.08)",
|
|
578
|
+
fontSize: "0.85rem",
|
|
579
|
+
}}
|
|
580
|
+
>
|
|
581
|
+
{detail.error && (
|
|
582
|
+
<div className="vi-error" role="alert" style={{ marginBottom: "0.4rem" }}>
|
|
583
|
+
<strong>
|
|
584
|
+
{detail.status === "cancelled" ? "Cancelled" : "Failed"} [
|
|
585
|
+
{detail.error.code ?? "error"}]
|
|
586
|
+
</strong>
|
|
587
|
+
{detail.error.retryable ? " · retryable" : ""}
|
|
588
|
+
{detail.error.message ? <div>{detail.error.message}</div> : null}
|
|
589
|
+
</div>
|
|
590
|
+
)}
|
|
591
|
+
{m ? (
|
|
592
|
+
<>
|
|
593
|
+
<DetailRow label="model" value={m.model_id} />
|
|
594
|
+
<DetailRow label="precision" value={m.precision} />
|
|
595
|
+
<DetailRow
|
|
596
|
+
label="resolution"
|
|
597
|
+
value={res ? `${res.width}×${res.height} @ ${res.fps}fps` : null}
|
|
598
|
+
/>
|
|
599
|
+
<DetailRow label="frames" value={m.frames} />
|
|
600
|
+
<DetailRow label="duration" value={m.duration_s != null ? `${m.duration_s}s` : null} />
|
|
601
|
+
<DetailRow label="seed" value={m.seed} />
|
|
602
|
+
<DetailRow
|
|
603
|
+
label="sampler"
|
|
604
|
+
value={
|
|
605
|
+
sampler.steps != null
|
|
606
|
+
? `${sampler.steps} steps · cfg ${sampler.cfg}${
|
|
607
|
+
sampler.shift != null ? ` · shift ${sampler.shift}` : ""
|
|
608
|
+
}`
|
|
609
|
+
: null
|
|
610
|
+
}
|
|
611
|
+
/>
|
|
612
|
+
<DetailRow label="prompt" value={m.prompt} />
|
|
613
|
+
<DetailRow label="negative" value={m.negative_prompt} />
|
|
614
|
+
<DetailRow label="source" value={m.source_video} />
|
|
615
|
+
<DetailRow label="hash" value={m.content_hash} />
|
|
616
|
+
</>
|
|
617
|
+
) : (
|
|
618
|
+
<>
|
|
619
|
+
{/* No manifest: show the REQUESTED spec so failures are legible, plainly
|
|
620
|
+
labeled as requested (the render bound no model). */}
|
|
621
|
+
<p
|
|
622
|
+
className="vi-comfy-hint"
|
|
623
|
+
role="note"
|
|
624
|
+
style={{ margin: "0 0 0.3rem", fontStyle: "italic", opacity: 0.85 }}
|
|
625
|
+
>
|
|
626
|
+
Requested parameters (this render did not complete — values were never
|
|
627
|
+
resolved; blank steps/cfg = model default).
|
|
628
|
+
</p>
|
|
629
|
+
<DetailRow label="capability" value={s.capability} />
|
|
630
|
+
<DetailRow
|
|
631
|
+
label="resolution"
|
|
632
|
+
value={s.width ? `${s.width}×${s.height} @ ${s.fps}fps` : null}
|
|
633
|
+
/>
|
|
634
|
+
<DetailRow label="budget" value={s.vram_budget_gb != null ? `${s.vram_budget_gb} GB` : null} />
|
|
635
|
+
<DetailRow label="seed" value={s.seed} />
|
|
636
|
+
<DetailRow label="steps (req)" value={s.steps} />
|
|
637
|
+
<DetailRow label="cfg (req)" value={s.cfg} />
|
|
638
|
+
<DetailRow label="model (pin)" value={s.model_id} />
|
|
639
|
+
<DetailRow label="prompt" value={s.prompt} />
|
|
640
|
+
<DetailRow label="negative" value={s.negative} />
|
|
641
|
+
<DetailRow label="start image" value={s.start_image} />
|
|
642
|
+
<DetailRow label="source" value={s.source_video} />
|
|
643
|
+
</>
|
|
644
|
+
)}
|
|
645
|
+
{/* Timestamps ride along in every case (from the bus row). */}
|
|
646
|
+
<DetailRow label="created" value={fmtWhen(detail.created)} />
|
|
647
|
+
<DetailRow label="updated" value={fmtWhen(detail.updated)} />
|
|
648
|
+
{requested && !m && (
|
|
649
|
+
<DetailRow label="source" value="job record (requested params)" />
|
|
650
|
+
)}
|
|
651
|
+
|
|
652
|
+
{/* STAGE TIMELINE — the retained per-stage history (what this render did + where
|
|
653
|
+
it stopped). Present for any row the bus recorded a timeline for; a terminal
|
|
654
|
+
failed/cancelled row shows the exact failing stage via the failure block. */}
|
|
655
|
+
{detail.stage_log && detail.stage_log.length > 0 && (
|
|
656
|
+
<div style={{ marginTop: "0.5rem" }}>
|
|
657
|
+
<ProcessTimeline
|
|
658
|
+
stageLog={detail.stage_log}
|
|
659
|
+
failure={
|
|
660
|
+
detail.error && detail.error.code
|
|
661
|
+
? {
|
|
662
|
+
stage: detail.error.stage ?? null,
|
|
663
|
+
code: detail.error.code ?? null,
|
|
664
|
+
message: detail.error.message ?? null,
|
|
665
|
+
retryable: detail.error.retryable ?? null,
|
|
666
|
+
}
|
|
667
|
+
: null
|
|
668
|
+
}
|
|
669
|
+
currentStage={detail.current_stage}
|
|
670
|
+
lastMovementTs={detail.last_movement_ts}
|
|
671
|
+
terminal
|
|
672
|
+
nowMs={Date.now()}
|
|
673
|
+
/>
|
|
674
|
+
</div>
|
|
675
|
+
)}
|
|
676
|
+
</div>
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// ── the durable clip catalog: list + light poll + Session-Library push ───────
|
|
681
|
+
|
|
682
|
+
export interface StudioClipsState {
|
|
683
|
+
clips: Clip[];
|
|
684
|
+
loading: boolean;
|
|
685
|
+
error: string | null;
|
|
686
|
+
/** Force an immediate (foreground) reload of the clip list. */
|
|
687
|
+
reload: () => void;
|
|
688
|
+
/** Force a quiet (background) reload — used right after an enqueue/cancel. */
|
|
689
|
+
refresh: () => void;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Load the durable studio clip catalog (GET /video/studio/clips) with a light 6s
|
|
694
|
+
* background poll so a freshly-produced clip appears without a manual refresh, and
|
|
695
|
+
* push every playable clip into the Session Library (idempotent by uri) so the
|
|
696
|
+
* studio's output is visible cross-station and itself "Send to Studio"-able. This
|
|
697
|
+
* is the original station's `load()` extracted verbatim so the shell owns it once.
|
|
698
|
+
*/
|
|
699
|
+
export function useStudioClips(): StudioClipsState {
|
|
700
|
+
const [clips, setClips] = useState<Clip[]>([]);
|
|
701
|
+
const [loading, setLoading] = useState(true);
|
|
702
|
+
const [error, setError] = useState<string | null>(null);
|
|
703
|
+
|
|
704
|
+
const mounted = useRef(true);
|
|
705
|
+
const inFlight = useRef(false);
|
|
706
|
+
// Job ids whose per-clip detail we've already fetched for prompt/model back-fill —
|
|
707
|
+
// so the back-fill runs ONCE per clip and never rides the 6s list poll.
|
|
708
|
+
const detailFetched = useRef<Set<string>>(new Set());
|
|
709
|
+
|
|
710
|
+
// Fill the REAL prompt (+ model) into the Session-Library item for a produced clip.
|
|
711
|
+
// The clips-LIST DTO (GET /video/studio/clips) carries no prompt — it lives in the
|
|
712
|
+
// bus row's spec_json and the clip's manifest.json, surfaced only by the per-clip
|
|
713
|
+
// detail route. So each playable clip's detail is fetched ONCE (gated by
|
|
714
|
+
// detailFetched, off the poll) and merged into its library entry, replacing the
|
|
715
|
+
// "(no text prompt)" placeholder the group header showed. Best-effort: an app-error
|
|
716
|
+
// response stays marked (no hammering); a thrown network error un-marks so a later
|
|
717
|
+
// poll can retry.
|
|
718
|
+
const backfillProvenance = useCallback(async (jobId: string, uri: string) => {
|
|
719
|
+
if (detailFetched.current.has(jobId)) return;
|
|
720
|
+
detailFetched.current.add(jobId);
|
|
721
|
+
try {
|
|
722
|
+
const res = await request<unknown>(studioClipDetailUrl(jobId), {
|
|
723
|
+
meta: { specKey: "studio", operation: "studio.clip.detail" },
|
|
724
|
+
});
|
|
725
|
+
if (!mounted.current) return;
|
|
726
|
+
if (!res.ok) return;
|
|
727
|
+
const parsed = clipDetailSchema.safeParse(okValue(res));
|
|
728
|
+
if (!parsed.success) return;
|
|
729
|
+
const d = parsed.data;
|
|
730
|
+
const rawSpecPrompt = d.spec?.prompt;
|
|
731
|
+
const specPrompt =
|
|
732
|
+
typeof rawSpecPrompt === "string" ? rawSpecPrompt : undefined;
|
|
733
|
+
// Prefer the RESOLVED manifest prompt (what actually rendered); fall back to the
|
|
734
|
+
// REQUESTED spec prompt for a clip whose manifest read didn't yield one.
|
|
735
|
+
const prompt = d.manifest?.prompt ?? specPrompt;
|
|
736
|
+
const model = d.manifest?.model_id ?? undefined;
|
|
737
|
+
if (prompt == null && model == null) return;
|
|
738
|
+
updateLibraryProvenance(uri, {
|
|
739
|
+
...(prompt != null ? { prompt } : {}),
|
|
740
|
+
...(model != null ? { model } : {}),
|
|
741
|
+
});
|
|
742
|
+
} catch {
|
|
743
|
+
// Transient/network failure — allow a later poll to retry the back-fill.
|
|
744
|
+
detailFetched.current.delete(jobId);
|
|
745
|
+
}
|
|
746
|
+
}, []);
|
|
747
|
+
|
|
748
|
+
const load = useCallback(async (background: boolean) => {
|
|
749
|
+
if (inFlight.current) return;
|
|
750
|
+
inFlight.current = true;
|
|
751
|
+
try {
|
|
752
|
+
// limit=200 (route max, 2026-08-13): the default 50-row window is
|
|
753
|
+
// newest-first with NO status filter, so a burst of failed test/battery
|
|
754
|
+
// jobs used to crowd every DONE clip out of the library. 200 + the
|
|
755
|
+
// archive sweep keeps the render history actually visible.
|
|
756
|
+
const res = await request<unknown>(`${hugpyConfig.studioClipsUrl}?limit=200`, {
|
|
757
|
+
meta: { specKey: "studio", operation: "studio.clips.list" },
|
|
758
|
+
});
|
|
759
|
+
if (!mounted.current) return;
|
|
760
|
+
if (!res.ok) {
|
|
761
|
+
if (background) return; // a failed poll must not wipe a working list
|
|
762
|
+
setLoading(false);
|
|
763
|
+
setError(describeAppError(errorOf(res)));
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
const parsed = clipsResponseSchema.safeParse(okValue(res));
|
|
767
|
+
if (!parsed.success) {
|
|
768
|
+
if (background) return;
|
|
769
|
+
setLoading(false);
|
|
770
|
+
setError("Malformed studio clips response.");
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
const list = parsed.data.clips;
|
|
774
|
+
setClips(list);
|
|
775
|
+
setLoading(false);
|
|
776
|
+
setError(null);
|
|
777
|
+
// Produced studio clips join the Session Library (a video ref per clip,
|
|
778
|
+
// grouped by its job id). addToLibrary de-dups by uri, so this idempotent
|
|
779
|
+
// push on every poll never double-adds. Only playable clips carrying a real
|
|
780
|
+
// uri + asset_id qualify.
|
|
781
|
+
for (const c of list) {
|
|
782
|
+
const o = c.output;
|
|
783
|
+
if (!c.playable || !o || !o.uri || !o.asset_id) continue;
|
|
784
|
+
const ref: MediaRef = {
|
|
785
|
+
asset_id: o.asset_id,
|
|
786
|
+
kind: "video",
|
|
787
|
+
uri: o.uri,
|
|
788
|
+
mime: o.mime ?? "video/mp4",
|
|
789
|
+
width: o.width ?? null,
|
|
790
|
+
height: o.height ?? null,
|
|
791
|
+
duration_s: o.duration_s ?? null,
|
|
792
|
+
};
|
|
793
|
+
addToLibrary(ref, "studio", "studio clip", {
|
|
794
|
+
groupId: c.job_id,
|
|
795
|
+
genKind: "studio_i2v",
|
|
796
|
+
});
|
|
797
|
+
// Back-fill the real prompt/model (the list DTO has neither) so the Session
|
|
798
|
+
// Library group shows the true prompt, not "(no text prompt)". Fire-and-forget,
|
|
799
|
+
// deduped + gated inside backfillProvenance so it costs one detail fetch/clip.
|
|
800
|
+
void backfillProvenance(c.job_id, o.uri);
|
|
801
|
+
}
|
|
802
|
+
} finally {
|
|
803
|
+
inFlight.current = false;
|
|
804
|
+
}
|
|
805
|
+
}, [backfillProvenance]);
|
|
806
|
+
|
|
807
|
+
useEffect(() => {
|
|
808
|
+
mounted.current = true;
|
|
809
|
+
void load(false);
|
|
810
|
+
const iv = window.setInterval(() => void load(true), 6000);
|
|
811
|
+
return () => {
|
|
812
|
+
mounted.current = false;
|
|
813
|
+
window.clearInterval(iv);
|
|
814
|
+
};
|
|
815
|
+
}, [load]);
|
|
816
|
+
|
|
817
|
+
const reload = useCallback(() => void load(false), [load]);
|
|
818
|
+
const refresh = useCallback(() => void load(true), [load]);
|
|
819
|
+
|
|
820
|
+
return { clips, loading, error, reload, refresh };
|
|
821
|
+
}
|