@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.
Files changed (177) hide show
  1. package/LICENSE +41 -0
  2. package/assets/sampels/images/bycycle/bd8603bad35d4f179f4d1134d8faf2ee.mp4 +0 -0
  3. package/assets/sampels/images/bycycle/frame_00000.png +0 -0
  4. package/assets/sampels/images/bycycle/frame_00001.png +0 -0
  5. package/assets/sampels/images/bycycle/frame_00002.png +0 -0
  6. package/assets/sampels/images/bycycle/frame_00003.png +0 -0
  7. package/assets/sampels/images/bycycle/frame_00004.png +0 -0
  8. package/assets/sampels/images/bycycle/frame_00005.png +0 -0
  9. package/assets/sampels/images/forrest/ff23090611b844a6b85c224f388bd542.mp4 +0 -0
  10. package/assets/sampels/images/forrest/frame_00000.png +0 -0
  11. package/assets/sampels/images/forrest/frame_00001.png +0 -0
  12. package/assets/sampels/images/forrest/frame_00002.png +0 -0
  13. package/assets/sampels/images/forrest/frame_00003.png +0 -0
  14. package/assets/sampels/images/forrest/frame_00004.png +0 -0
  15. package/assets/sampels/images/forrest/frame_00005.png +0 -0
  16. package/assets/sampels/images/shenzo/e1a4fcd623084ab4a986d11c8cd00db8.mp4 +0 -0
  17. package/assets/sampels/images/shenzo/frame_00000.png +0 -0
  18. package/assets/sampels/images/shenzo/frame_00001.png +0 -0
  19. package/assets/sampels/images/shenzo/frame_00002.png +0 -0
  20. package/assets/sampels/images/shenzo/frame_00003.png +0 -0
  21. package/assets/sampels/images/shenzo/frame_00004.png +0 -0
  22. package/assets/sampels/images/shenzo/frame_00005.png +0 -0
  23. package/assets/sampels/scenes/man.mp4 +0 -0
  24. package/assets/sampels/scenes/woman.mp4 +0 -0
  25. package/assets/sampels/studio_movies/caverns/_assembly/contrib_00.mp4 +0 -0
  26. package/assets/sampels/studio_movies/caverns/movie.json +67 -0
  27. package/assets/sampels/studio_movies/caverns/movie.mp4 +0 -0
  28. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/clip.mp4 +0 -0
  29. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/manifest.json +87 -0
  30. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/provenance.json +7 -0
  31. package/assets/sampels/studio_movies/caverns/segment_01/branch.png +0 -0
  32. package/assets/sampels/studio_movies/keeper-matrix/_assembly/concat_list.txt +2 -0
  33. package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_00.mp4 +0 -0
  34. package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_01.mp4 +0 -0
  35. package/assets/sampels/studio_movies/keeper-matrix/movie.json +81 -0
  36. package/assets/sampels/studio_movies/keeper-matrix/movie.mp4 +0 -0
  37. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/clip.mp4 +0 -0
  38. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/manifest.json +87 -0
  39. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/provenance.json +7 -0
  40. package/assets/sampels/studio_movies/keeper-matrix/segment_01/branch.png +0 -0
  41. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/clip.mp4 +0 -0
  42. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/manifest.json +87 -0
  43. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/provenance.json +7 -0
  44. package/assets/sampels/videos/a_red_paper_boat_drifting_on_a_calm_pond__gentle_ripples__mo_a62d6c28.mp4 +0 -0
  45. package/assets/sampels/videos/aurora.mp4 +0 -0
  46. package/assets/sampels/videos/dark_night.mp4 +0 -0
  47. package/assets/sampels/videos/fire_and_soul.mp4 +0 -0
  48. package/assets/sampels/videos/harsh_seas.mp4 +0 -0
  49. package/entry.tsx +74 -0
  50. package/index.html +53 -0
  51. package/package.json +42 -0
  52. package/src/assets/hugpy-mark.png +0 -0
  53. package/src/config.ts +676 -0
  54. package/src/demo/DemoBanner.tsx +54 -0
  55. package/src/demo/assets/demo-audio.mp3 +0 -0
  56. package/src/demo/assets/demo-clip.mp4 +0 -0
  57. package/src/demo/assets/demo-crop.mp4 +0 -0
  58. package/src/demo/assets/demo-poster.jpg +0 -0
  59. package/src/demo/assets/frame-1.jpg +0 -0
  60. package/src/demo/assets/frame-2.jpg +0 -0
  61. package/src/demo/assets/frame-3.jpg +0 -0
  62. package/src/demo/assets/frame-4.jpg +0 -0
  63. package/src/demo/assets/frame-5.jpg +0 -0
  64. package/src/demo/assets/frame-6.jpg +0 -0
  65. package/src/demo/assets/gen-1.jpg +0 -0
  66. package/src/demo/assets/gen-2.jpg +0 -0
  67. package/src/demo/assets.d.ts +14 -0
  68. package/src/demo/demo.css +64 -0
  69. package/src/demo/demoFetch.ts +305 -0
  70. package/src/demo/fixtures.ts +814 -0
  71. package/src/demo/index.ts +43 -0
  72. package/src/demo/mode.ts +81 -0
  73. package/src/demo/promptAssist.ts +181 -0
  74. package/src/demo/seed.ts +97 -0
  75. package/src/nav/BrandMark.css +66 -0
  76. package/src/nav/BrandMark.tsx +40 -0
  77. package/src/nav/Navbar.css +13 -0
  78. package/src/nav/Navbar.tsx +68 -0
  79. package/src/regions/SpatialRegionEditor.tsx +375 -0
  80. package/src/regions/TemporalRegionEditor.tsx +485 -0
  81. package/src/regions/types.ts +85 -0
  82. package/src/session.ts +85 -0
  83. package/src/share.ts +77 -0
  84. package/src/stations/ActivePanel.tsx +55 -0
  85. package/src/stations/ActiveProcessesStation.tsx +272 -0
  86. package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +228 -0
  87. package/src/stations/AddFileBar.tsx +59 -0
  88. package/src/stations/AudioCropStation.tsx +157 -0
  89. package/src/stations/ComfyPanel.tsx +210 -0
  90. package/src/stations/ComfyStation.tsx +196 -0
  91. package/src/stations/CondensedKnobStrip.tsx +245 -0
  92. package/src/stations/ConsolePanel.tsx +82 -0
  93. package/src/stations/FrameExtractStation.tsx +148 -0
  94. package/src/stations/GenIdentityBar.tsx +412 -0
  95. package/src/stations/GenerateStation.tsx +5603 -0
  96. package/src/stations/GenerateStation.tsx.bak-tester-removal +5170 -0
  97. package/src/stations/GoalComposer.tsx +139 -0
  98. package/src/stations/ImageCropStation.tsx +153 -0
  99. package/src/stations/KnobInfo.tsx +64 -0
  100. package/src/stations/PlaceholderStation.tsx +26 -0
  101. package/src/stations/SectionTabs.tsx +276 -0
  102. package/src/stations/SharePanel.tsx +298 -0
  103. package/src/stations/SidebarRegistry.tsx +105 -0
  104. package/src/stations/StationShell.tsx +96 -0
  105. package/src/stations/StudioClipsStation.tsx +28 -0
  106. package/src/stations/WorkbenchDrawer.tsx +66 -0
  107. package/src/stations/WorkbenchSidebar.tsx +1224 -0
  108. package/src/stations/WorkbenchStation.tsx +91 -0
  109. package/src/stations/comfyEmbed.ts +79 -0
  110. package/src/stations/drawerShell.tsx +419 -0
  111. package/src/stations/ops/AudioCropCore.tsx +218 -0
  112. package/src/stations/ops/CharacterGroupsPanel.tsx +716 -0
  113. package/src/stations/ops/FrameExtractCore.tsx +695 -0
  114. package/src/stations/ops/ImageCropCore.tsx +164 -0
  115. package/src/stations/ops/VideoCropCore.tsx +193 -0
  116. package/src/stations/ops/useVideoCropJobs.ts +224 -0
  117. package/src/stations/pollCaps.ts +87 -0
  118. package/src/stations/registry.ts +191 -0
  119. package/src/stations/studio/AngleRingEditor.tsx +159 -0
  120. package/src/stations/studio/IdentitiesStation.tsx +2455 -0
  121. package/src/stations/studio/IdentityProfileControls.tsx +262 -0
  122. package/src/stations/studio/StudioActiveProcesses.tsx +224 -0
  123. package/src/stations/studio/StudioAssistLogPanel.tsx +220 -0
  124. package/src/stations/studio/StudioGenerateMode.tsx +29 -0
  125. package/src/stations/studio/StudioGenerateTab.tsx +2688 -0
  126. package/src/stations/studio/StudioItemCard.tsx +334 -0
  127. package/src/stations/studio/StudioLibraryTab.tsx +506 -0
  128. package/src/stations/studio/StudioMovieComposer.tsx +2875 -0
  129. package/src/stations/studio/StudioMovieComposer.tsx.bak-sessions-20260812 +2729 -0
  130. package/src/stations/studio/StudioMovieSessions.tsx +553 -0
  131. package/src/stations/studio/StudioMovieSessions.tsx.bak-sessions-20260812 +417 -0
  132. package/src/stations/studio/StudioPlane.tsx +302 -0
  133. package/src/stations/studio/StudioSplicedRow.tsx +476 -0
  134. package/src/stations/studio/StudioViewer.tsx +339 -0
  135. package/src/stations/studio/TurntableViewer.tsx +220 -0
  136. package/src/stations/studio/movieTimeline.ts +198 -0
  137. package/src/stations/studio/promptCard.tsx +498 -0
  138. package/src/stations/studio/studioItem.ts +87 -0
  139. package/src/stations/studio/studioShared.tsx +821 -0
  140. package/src/stations/studio/useIdentityProfiles.ts +575 -0
  141. package/src/stations/studio/useReconstruction.ts +283 -0
  142. package/src/stations/types.ts +43 -0
  143. package/src/stations/useAudioCropJobs.ts +202 -0
  144. package/src/stations/useAudioExtractJob.ts +144 -0
  145. package/src/stations/useCropJobs.ts +206 -0
  146. package/src/stations/useFrameJobs.ts +140 -0
  147. package/src/stations/useGenerateJob.ts +151 -0
  148. package/src/stations/useGenerateMovieJob.ts +177 -0
  149. package/src/stations/useGenerateRowJobs.ts +184 -0
  150. package/src/stations/useGenerateSceneJob.ts +156 -0
  151. package/src/stations/useGenerateSceneRowJobs.ts +282 -0
  152. package/src/style/app.css +4974 -0
  153. package/src/transport/client.ts +262 -0
  154. package/src/transport/telemetry.ts +97 -0
  155. package/src/video/DropReceptacle.tsx +122 -0
  156. package/src/video/PromptAssistButtons.tsx +118 -0
  157. package/src/video/composerBridge.ts +66 -0
  158. package/src/video/contract.ts +599 -0
  159. package/src/video/jobTracker.ts +702 -0
  160. package/src/video/mediaLibrary.ts +328 -0
  161. package/src/video/renderCompat.ts +512 -0
  162. package/src/video/sessionForm.ts +189 -0
  163. package/src/video/spreadAssist.ts +392 -0
  164. package/src/video/studioBridge.ts +82 -0
  165. package/src/video/useMediaJobs.ts +279 -0
  166. package/src/video/useModels.ts +413 -0
  167. package/src/video/useMoviePresets.ts +97 -0
  168. package/src/video/useMovieSessions.ts +292 -0
  169. package/src/video/usePresets.ts +167 -0
  170. package/src/video/useProjects.ts +87 -0
  171. package/src/video/usePromptAssist.ts +394 -0
  172. package/src/video/useRenderPresets.ts +221 -0
  173. package/src/video/useStudioAssistLog.ts +266 -0
  174. package/src/video/useStudioPresets.ts +128 -0
  175. package/src/video/useWorkerCalls.ts +165 -0
  176. package/tsconfig.json +17 -0
  177. package/vite.config.ts +33 -0
@@ -0,0 +1,695 @@
1
+ // FrameExtractCore — the reusable body of the Frames & Models station (Studio
2
+ // slice 2a). The station's working body MINUS the upload lifecycle: the Options
3
+ // knob rail (fps / quality / fmt / window / max_frames + the shared image-model
4
+ // dropdown) + the useFrameJobs enqueue hook + the paginated frame grid + the
5
+ // frame-pick → library flow, parameterised by a `source` ref.
6
+ //
7
+ // Unlike the crop cores (which fill a single `components` slot), the Frames station
8
+ // has an Options panel whose knob state is shared with the working view, so this
9
+ // Core owns the whole `SectionTabs` (both slots) and takes the station's `addFile`
10
+ // node + `busy` flag as props. Behaviour is IDENTICAL to the pre-slice-2a
11
+ // FrameExtractStation body: same Options rail, same preview/grid/pager/footer, same
12
+ // classes, same run gate. `onProduced` is an OPTIONAL slice-2b sink fired for each
13
+ // frame picked into the library; when OMITTED (the station case) the pick still
14
+ // pushes to the shared library exactly as before.
15
+ //
16
+ // Source lifecycle: the station owned `reset()` + clearing the selection/page inside
17
+ // onPick/clearSource. The Core now owns the job hook + selection, so it resets them
18
+ // whenever the `source` REFERENCE changes (a new pick or a clear). The initial mount
19
+ // is skipped (prevSource seed) so a remount after a sub-tab switch keeps the durable
20
+ // tracker's finished frames.
21
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
22
+ import { useFrameJobs } from "../useFrameJobs";
23
+ import { SectionTabs } from "../SectionTabs";
24
+ import { useSidebarRegistry } from "../SidebarRegistry";
25
+ // t25: char360 character view-set extraction, reused WHOLESALE from the identities
26
+ // station (zero new backend — same extractFromVideo + GET /video/jobs/<id> plumbing).
27
+ import { ExtractFromVideoPanel } from "../studio/IdentitiesStation";
28
+ import { CharacterGroupsPanel } from "./CharacterGroupsPanel";
29
+ import { useIdentityProfiles } from "../studio/useIdentityProfiles";
30
+ import { STILL_RUNNING_MESSAGE } from "../pollCaps";
31
+ import { useImageModels } from "../../video/useModels";
32
+ import { addToLibrary, useMediaLibrary } from "../../video/mediaLibrary";
33
+ import { mediaBytesUrl } from "../../config";
34
+ import {
35
+ FRAME_FORMATS,
36
+ type FrameFormat,
37
+ type FrameExtractRequest,
38
+ type MediaRef,
39
+ } from "../../video/contract";
40
+
41
+ const PAGE_SIZE = 60;
42
+ const MODEL_KEY = "vi.frames.imageModel.v1";
43
+
44
+ // quality semantics differ by fmt — surfaced, never silently clamped.
45
+ function qualityRange(fmt: FrameFormat): { min: number; max: number; hint: string } {
46
+ if (fmt === "png") {
47
+ return {
48
+ min: 0,
49
+ max: 9,
50
+ hint: "PNG · 0–9 compression level (0 = fastest/largest, 9 = smallest).",
51
+ };
52
+ }
53
+ return {
54
+ min: 1,
55
+ max: 100,
56
+ hint: `${fmt.toUpperCase()} · 1–100 quality (higher = better, larger file).`,
57
+ };
58
+ }
59
+
60
+ // Parse a text field into a positive number, or null when blank/invalid.
61
+ function toNumberOrNull(raw: string): number | null {
62
+ const s = raw.trim();
63
+ if (s === "") return null;
64
+ const n = Number(s);
65
+ return Number.isFinite(n) ? n : null;
66
+ }
67
+
68
+ // ── INTELLIGENT DEFAULTS (t23; defaults-are-promises) ────────────────────────
69
+ // A blank knob resolves to a real, source-informed value so a bare Run on a
70
+ // typical clip yields a useful extraction with zero operator input. The resolved
71
+ // values are shown in the UI (never a field that "secretly computes").
72
+ //
73
+ // fps ← clip DURATION. We aim for a total frame count in a useful review band and
74
+ // back out the fps that lands there, clamped to sane bounds:
75
+ // target ≈ 180 frames (mid of a ~100–300 band), fps = target / duration_s,
76
+ // clamped to [0.5, 8] fps. Rounded to a friendly step (≥2fps → integer;
77
+ // <2fps → one decimal) so the hint reads cleanly. With no duration (unknown
78
+ // source) we fall back to a flat 2 fps — a sane general default.
79
+ const FPS_TARGET_FRAMES = 180; // mid of the ~100–300 useful band
80
+ const FPS_MIN = 0.5;
81
+ const FPS_MAX = 8;
82
+ const FPS_FALLBACK = 2; // duration unknown → a sane flat default
83
+
84
+ function deriveFps(durationS: number | null | undefined): number {
85
+ if (!durationS || durationS <= 0) return FPS_FALLBACK;
86
+ const raw = FPS_TARGET_FRAMES / durationS;
87
+ const clamped = Math.min(FPS_MAX, Math.max(FPS_MIN, raw));
88
+ // Friendly rounding: whole fps at ≥2, one decimal below (0.5 steps read well).
89
+ return clamped >= 2 ? Math.round(clamped) : Math.round(clamped * 2) / 2;
90
+ }
91
+
92
+ // The resulting frame count at a given fps over a duration (for the honest hint).
93
+ function framesAt(fps: number, durationS: number | null | undefined): number | null {
94
+ if (!durationS || durationS <= 0 || !fps || fps <= 0) return null;
95
+ return Math.max(1, Math.round(fps * durationS));
96
+ }
97
+
98
+ // quality ← a high, format-appropriate constant. jpg/webp are 1..100 (higher =
99
+ // better) so 90 is near-max fidelity at a reasonable file size; png is 0..9
100
+ // COMPRESSION (higher = smaller) so 6 is a balanced size/speed default. These are
101
+ // the "good result on a typical clip" constants — never left blank.
102
+ function defaultQuality(fmt: FrameFormat): number {
103
+ return fmt === "png" ? 6 : 90;
104
+ }
105
+
106
+ export interface FrameExtractCoreProps {
107
+ /** Source video (kind==="video"), or null before one is loaded. */
108
+ source: MediaRef | null;
109
+ /** Upload/ingest in flight (station-owned) — gates Run, exactly as before. */
110
+ busy: boolean;
111
+ /** The persistent add-file bar node (SectionTabs' addFile slot) — station-owned. */
112
+ addFile: ReactNode;
113
+ /** Additive (slice 2b): notified for each frame picked into the library.
114
+ Omitted by the station — picks still push to the shared library regardless. */
115
+ onProduced?: (ref: MediaRef) => void;
116
+ }
117
+
118
+ export function FrameExtractCore({
119
+ source,
120
+ busy,
121
+ addFile,
122
+ onProduced,
123
+ }: FrameExtractCoreProps) {
124
+ // ---- knob state (all explicit; fps/quality start empty and must be set) ----
125
+ const [fps, setFps] = useState<string>("");
126
+ const [fmt, setFmt] = useState<FrameFormat>("jpg");
127
+ const [quality, setQuality] = useState<string>("");
128
+ const [windowOn, setWindowOn] = useState<boolean>(false);
129
+ const [startS, setStartS] = useState<string>("");
130
+ const [endS, setEndS] = useState<string>("");
131
+ const [maxFrames, setMaxFrames] = useState<string>("");
132
+
133
+ // ---- shared image-model dropdown (config surface reused by Generate) ----
134
+ const { models, defaultId, loading: modelsLoading, error: modelsError, refresh: refreshModels } =
135
+ useImageModels();
136
+ const [modelId, setModelId] = useState<string>(() => {
137
+ try {
138
+ return sessionStorage.getItem(MODEL_KEY) ?? "";
139
+ } catch {
140
+ return "";
141
+ }
142
+ });
143
+ // Seed the selection from the task default once models load (only if unset).
144
+ useEffect(() => {
145
+ if (!modelId && defaultId) setModelId(defaultId);
146
+ }, [defaultId, modelId]);
147
+ useEffect(() => {
148
+ if (!modelId) return;
149
+ try {
150
+ sessionStorage.setItem(MODEL_KEY, modelId);
151
+ } catch {
152
+ /* ignore */
153
+ }
154
+ }, [modelId]);
155
+
156
+ // ---- job lifecycle ----
157
+ const { status, frames, error: jobError, capped, running, run, resume, reset } =
158
+ useFrameJobs();
159
+
160
+ // ---- Settings tab in the LEFT sidebar (t23) ----
161
+ // The Options knob rail folds into the arm shell's ONE left sidebar "Settings"
162
+ // tab (the same pattern GenerateStation uses): register reveals the guarded
163
+ // Settings tab, and SectionTabs portals the options node into reg.settingsHost
164
+ // — the work column then goes full width. Same shell, no variant.
165
+ const reg = useSidebarRegistry();
166
+ useEffect(() => reg.registerSettings(), [reg.registerSettings]);
167
+
168
+ // ---- CHARACTER tools (t25): char360 view-set extraction over the loaded video ----
169
+ // Reuses the identities station's ExtractFromVideoPanel + its extractFromVideo
170
+ // hook wholesale (same GET /video/jobs/<id> polling). The station already loads a
171
+ // VIDEO source, so char360 runs against that same source — no second upload hop.
172
+ const { profiles: idProfiles, extractFromVideo, reload: reloadIdentities } =
173
+ useIdentityProfiles();
174
+ const sourceIsVideo = source?.kind === "video";
175
+
176
+ // ---- frame selection + pagination ----
177
+ const [selected, setSelected] = useState<Set<string>>(new Set());
178
+ const [page, setPage] = useState<number>(0);
179
+ const library = useMediaLibrary();
180
+ const inLibrary = useMemo(
181
+ () => new Set(library.map((it) => it.ref.uri)),
182
+ [library],
183
+ );
184
+
185
+ const qr = qualityRange(fmt);
186
+
187
+ // Reset the job + selection when a NEW source is picked (or cleared) — keyed on the
188
+ // source reference so it fires exactly on a pick/clear. The mount is skipped
189
+ // (prevSource seed) so a remount after a tab switch keeps the durable tracker's
190
+ // finished frames.
191
+ const prevSource = useRef<MediaRef | null>(source);
192
+ useEffect(() => {
193
+ if (prevSource.current === source) return;
194
+ prevSource.current = source;
195
+ reset();
196
+ setSelected(new Set());
197
+ setPage(0);
198
+ // eslint-disable-next-line react-hooks/exhaustive-deps
199
+ }, [source]);
200
+
201
+ // ---- run validation (no silent defaults; every gate is visible) ----
202
+ const fpsNum = toNumberOrNull(fps);
203
+ const qualityNum = toNumberOrNull(quality);
204
+ const maxFramesNum = toNumberOrNull(maxFrames);
205
+ const startNum = toNumberOrNull(startS);
206
+ const endNum = toNumberOrNull(endS);
207
+
208
+ // ---- resolved (effective) values (t23) ----
209
+ // A BLANK field resolves to its intelligent default: fps from the source's
210
+ // duration, quality from the format. A TYPED value overrides. These are what
211
+ // Run sends and what the UI surfaces as the honest "(auto: …)" hint, so a bare
212
+ // Run on a typical clip produces a good extraction with no operator input.
213
+ const autoFps = deriveFps(source?.duration_s);
214
+ const autoQuality = defaultQuality(fmt);
215
+ const effectiveFps = fpsNum != null ? fpsNum : autoFps;
216
+ const effectiveQuality = qualityNum != null ? qualityNum : autoQuality;
217
+ const fpsIsAuto = fpsNum == null; // blank → showing the derived default
218
+ const qualityIsAuto = qualityNum == null;
219
+ const autoFrames = framesAt(effectiveFps, source?.duration_s);
220
+
221
+ // A field is INVALID only when NON-BLANK and out of range — blank is valid now
222
+ // (it means "use the auto default"), never a Run-blocking empty.
223
+ const fpsValid = fpsNum == null || fpsNum > 0;
224
+ const qualityValid =
225
+ qualityNum == null ||
226
+ (Number.isInteger(qualityNum) && qualityNum >= qr.min && qualityNum <= qr.max);
227
+ const maxFramesValid =
228
+ maxFrames.trim() === "" ||
229
+ (maxFramesNum != null && Number.isInteger(maxFramesNum) && maxFramesNum > 0);
230
+ const windowValid =
231
+ !windowOn ||
232
+ (startNum != null && endNum != null && startNum >= 0 && endNum > startNum);
233
+
234
+ const canRun =
235
+ !!source &&
236
+ !busy &&
237
+ !running &&
238
+ fpsValid &&
239
+ qualityValid &&
240
+ maxFramesValid &&
241
+ windowValid;
242
+
243
+ function onRun() {
244
+ if (!source) return;
245
+ setSelected(new Set());
246
+ setPage(0);
247
+ const req: FrameExtractRequest = {
248
+ source,
249
+ // Send the RESOLVED values — a blank field ships its intelligent default.
250
+ fps: effectiveFps,
251
+ quality: effectiveQuality,
252
+ fmt,
253
+ window: windowOn && startNum != null && endNum != null
254
+ ? { start_s: startNum, end_s: endNum }
255
+ : null,
256
+ max_frames: maxFrames.trim() === "" ? null : maxFramesNum,
257
+ };
258
+ run(req);
259
+ }
260
+
261
+ // ---- selection helpers ----
262
+ function toggleFrame(uri: string) {
263
+ setSelected((prev) => {
264
+ const next = new Set(prev);
265
+ if (next.has(uri)) next.delete(uri);
266
+ else next.add(uri);
267
+ return next;
268
+ });
269
+ }
270
+
271
+ const pageCount = Math.max(1, Math.ceil(frames.length / PAGE_SIZE));
272
+ const clampedPage = Math.min(page, pageCount - 1);
273
+ const pageStart = clampedPage * PAGE_SIZE;
274
+ const pageFrames = frames.slice(pageStart, pageStart + PAGE_SIZE);
275
+
276
+ function selectAllOnPage() {
277
+ setSelected((prev) => {
278
+ const next = new Set(prev);
279
+ for (const f of pageFrames) next.add(f.uri);
280
+ return next;
281
+ });
282
+ }
283
+ function clearSelection() {
284
+ setSelected(new Set());
285
+ }
286
+
287
+ // Push every selected frame into the shared library (dedupes by uri). Frames the
288
+ // user unselects are NOT removed from the library (house policy: no deletion).
289
+ function addSelectedToGenerate() {
290
+ let n = 0;
291
+ frames.forEach((f, i) => {
292
+ if (selected.has(f.uri)) {
293
+ addToLibrary(f, "frames", `frame ${i + 1}`);
294
+ onProduced?.(f);
295
+ n += 1;
296
+ }
297
+ });
298
+ return n;
299
+ }
300
+
301
+ const selectedCount = selected.size;
302
+ const showGrid = frames.length > 0;
303
+
304
+ // The large center preview mirrors the strip: the first selected frame, or the
305
+ // first extracted frame when nothing is selected yet. Pure derivation — no new
306
+ // interaction, no change to the poll/selection contract.
307
+ const focusFrame = useMemo(() => {
308
+ const sel = frames.find((f) => selected.has(f.uri));
309
+ return sel ?? frames[0] ?? null;
310
+ }, [frames, selected]);
311
+
312
+ return (
313
+ <SectionTabs
314
+ ariaLabel="Frames station"
315
+ initialTab="components"
316
+ addFile={addFile}
317
+ optionsLabel="Settings"
318
+ optionsHost={reg.settingsHost}
319
+ options={
320
+ /* OPTIONS — explicit knobs, no silent defaults. */
321
+ <aside className="vi-frames-rail">
322
+ <div className="vi-knob">
323
+ <label htmlFor="vi-model">Image model</label>
324
+ <select
325
+ id="vi-model"
326
+ className="vi-knob-select"
327
+ value={modelId}
328
+ onChange={(e) => setModelId(e.target.value)}
329
+ disabled={modelsLoading}
330
+ >
331
+ {modelsLoading && <option value="">Loading models…</option>}
332
+ {!modelsLoading && models.every((m) => m.disabled) && (
333
+ <option value="">No image models available</option>
334
+ )}
335
+ {/* Adapters / pipeline components / unclassified rows appear greyed
336
+ with the backend's reason instead of being offered (k61). */}
337
+ {models.map((m) => (
338
+ <option key={m.id} value={m.id} disabled={m.disabled} title={m.reason}>
339
+ {m.label}
340
+ </option>
341
+ ))}
342
+ </select>
343
+ <button
344
+ type="button"
345
+ className="vi-btn vi-btn-sm vi-btn-ghost"
346
+ onClick={refreshModels}
347
+ disabled={modelsLoading}
348
+ title="Re-scan the registry for image models adopted this session"
349
+ >
350
+ ↻ Refresh models
351
+ </button>
352
+ <span className="vi-knob-hint">
353
+ Shared image model — carried to the Generate station. Not used to
354
+ extract frames.
355
+ </span>
356
+ {modelsError && <span className="vi-error">{modelsError}</span>}
357
+ </div>
358
+
359
+ <div className="vi-knob">
360
+ <label htmlFor="vi-fps">fps</label>
361
+ <input
362
+ id="vi-fps"
363
+ type="number"
364
+ min="0"
365
+ step="any"
366
+ inputMode="decimal"
367
+ className="vi-knob-input"
368
+ placeholder={`auto: ${autoFps}`}
369
+ value={fps}
370
+ onChange={(e) => setFps(e.target.value)}
371
+ />
372
+ <span className="vi-knob-hint">
373
+ Frames sampled per second of video.{" "}
374
+ {fpsIsAuto ? (
375
+ <span className="vi-knob-auto">
376
+ Using {effectiveFps} fps (auto
377
+ {autoFrames != null && source?.duration_s
378
+ ? `: ~${autoFrames} frames from ${Math.round(source.duration_s)}s`
379
+ : ""}
380
+ ). Type to override.
381
+ </span>
382
+ ) : (
383
+ autoFrames != null && (
384
+ <span className="vi-knob-auto">≈ {autoFrames} frames.</span>
385
+ )
386
+ )}
387
+ {!fpsValid && fps.trim() !== "" && (
388
+ <span className="vi-knob-flag"> Enter a positive number.</span>
389
+ )}
390
+ </span>
391
+ </div>
392
+
393
+ <div className="vi-knob">
394
+ <label htmlFor="vi-fmt">format</label>
395
+ <select
396
+ id="vi-fmt"
397
+ className="vi-knob-select"
398
+ value={fmt}
399
+ onChange={(e) => setFmt(e.target.value as FrameFormat)}
400
+ >
401
+ {FRAME_FORMATS.map((f) => (
402
+ <option key={f} value={f}>
403
+ {f}
404
+ </option>
405
+ ))}
406
+ </select>
407
+ </div>
408
+
409
+ <div className="vi-knob">
410
+ <label htmlFor="vi-quality">quality</label>
411
+ <input
412
+ id="vi-quality"
413
+ type="number"
414
+ min={qr.min}
415
+ max={qr.max}
416
+ step="1"
417
+ inputMode="numeric"
418
+ className="vi-knob-input"
419
+ placeholder={`auto: ${autoQuality}`}
420
+ value={quality}
421
+ onChange={(e) => setQuality(e.target.value)}
422
+ />
423
+ <span className="vi-knob-hint vi-knob-range">{qr.hint}</span>
424
+ {qualityIsAuto && (
425
+ <span className="vi-knob-auto">
426
+ Using {effectiveQuality} ({fmt === "png" ? "balanced compression" : "high quality"}). Type to override.
427
+ </span>
428
+ )}
429
+ {!qualityValid && quality.trim() !== "" && (
430
+ <span className="vi-knob-flag">
431
+ Enter an integer in {qr.min}–{qr.max} for {fmt.toUpperCase()}.
432
+ </span>
433
+ )}
434
+ </div>
435
+
436
+ <div className="vi-knob">
437
+ <label className="vi-knob-check">
438
+ <input
439
+ type="checkbox"
440
+ checked={windowOn}
441
+ onChange={(e) => setWindowOn(e.target.checked)}
442
+ />
443
+ window (time slice)
444
+ </label>
445
+ {windowOn ? (
446
+ <div className="vi-knob-window">
447
+ <label className="vi-knob-sub">
448
+ start_s
449
+ <input
450
+ type="number"
451
+ min="0"
452
+ step="any"
453
+ className="vi-knob-input vi-knob-input-sm"
454
+ value={startS}
455
+ onChange={(e) => setStartS(e.target.value)}
456
+ />
457
+ </label>
458
+ <label className="vi-knob-sub">
459
+ end_s
460
+ <input
461
+ type="number"
462
+ min="0"
463
+ step="any"
464
+ className="vi-knob-input vi-knob-input-sm"
465
+ value={endS}
466
+ onChange={(e) => setEndS(e.target.value)}
467
+ />
468
+ </label>
469
+ </div>
470
+ ) : (
471
+ <span className="vi-knob-hint">
472
+ Off — the WHOLE video is extracted (window: null).
473
+ </span>
474
+ )}
475
+ {windowOn && !windowValid && (
476
+ <span className="vi-knob-flag">
477
+ Need 0 ≤ start &lt; end (seconds).
478
+ </span>
479
+ )}
480
+ </div>
481
+
482
+ <div className="vi-knob">
483
+ <label htmlFor="vi-maxframes">max_frames (optional)</label>
484
+ <input
485
+ id="vi-maxframes"
486
+ type="number"
487
+ min="1"
488
+ step="1"
489
+ inputMode="numeric"
490
+ className="vi-knob-input"
491
+ placeholder="uncapped"
492
+ value={maxFrames}
493
+ onChange={(e) => setMaxFrames(e.target.value)}
494
+ />
495
+ <span className="vi-knob-hint vi-knob-flag">
496
+ Hard cap. If the extraction would exceed it, the job FAILS LOUDLY
497
+ (status: failed) — it never silently truncates.
498
+ </span>
499
+ {!maxFramesValid && (
500
+ <span className="vi-knob-flag">Enter a positive integer or leave blank.</span>
501
+ )}
502
+ </div>
503
+
504
+ </aside>
505
+ }
506
+ components={
507
+ /* COMPONENTS — the working UI: preview over the frame grid, with the
508
+ post-extract toolbar + Run action as an in-panel footer. */
509
+ <div className="vi-station-components">
510
+ {/* Preview area — ALWAYS rendered (blank frame area when no video is
511
+ loaded). The Options, add-file bar, and Run footer stay visible
512
+ regardless. */}
513
+ <div className="vi-studio-preview">
514
+ {focusFrame ? (
515
+ <img
516
+ className="vi-studio-preview-img"
517
+ src={mediaBytesUrl(focusFrame.uri)}
518
+ alt="Selected frame"
519
+ />
520
+ ) : running ? (
521
+ <p className="vi-crop-hint">
522
+ Working… frames appear here when the job finishes.
523
+ </p>
524
+ ) : source ? (
525
+ <p className="vi-crop-hint">
526
+ Set the options and run to extract frames. Each frame lands as a
527
+ MediaRef (never inlined); pick the ones you want for Generate.
528
+ </p>
529
+ ) : null}
530
+ </div>
531
+
532
+ {showGrid && (
533
+ <div className="vi-frames-grid vi-studio-strip">
534
+ {pageFrames.map((f, i) => {
535
+ const globalIdx = pageStart + i;
536
+ const picked = selected.has(f.uri);
537
+ const saved = inLibrary.has(f.uri);
538
+ return (
539
+ <button
540
+ type="button"
541
+ key={f.uri}
542
+ className={
543
+ picked ? "vi-frame-cell vi-frame-cell-selected" : "vi-frame-cell"
544
+ }
545
+ onClick={() => toggleFrame(f.uri)}
546
+ aria-pressed={picked}
547
+ title={f.uri}
548
+ >
549
+ <img
550
+ className="vi-frame-img"
551
+ src={mediaBytesUrl(f.uri)}
552
+ alt={`Frame ${globalIdx + 1}`}
553
+ loading="lazy"
554
+ />
555
+ <span className="vi-frame-tag">#{globalIdx + 1}</span>
556
+ {picked && <span className="vi-frame-check">✓</span>}
557
+ {saved && <span className="vi-frame-saved">in library</span>}
558
+ </button>
559
+ );
560
+ })}
561
+ </div>
562
+ )}
563
+
564
+ {showGrid && pageCount > 1 && (
565
+ <div className="vi-pager">
566
+ <button
567
+ type="button"
568
+ className="vi-btn vi-btn-sm"
569
+ disabled={clampedPage === 0}
570
+ onClick={() => setPage(clampedPage - 1)}
571
+ >
572
+ ‹ Prev
573
+ </button>
574
+ <span className="vi-pager-label">
575
+ page {clampedPage + 1} / {pageCount}
576
+ </span>
577
+ <button
578
+ type="button"
579
+ className="vi-btn vi-btn-sm"
580
+ disabled={clampedPage >= pageCount - 1}
581
+ onClick={() => setPage(clampedPage + 1)}
582
+ >
583
+ Next ›
584
+ </button>
585
+ </div>
586
+ )}
587
+
588
+ {/* FOOTER — post-extract toolbar + primary Run action, INSIDE the
589
+ components panel (nothing floats outside the panels). */}
590
+ <div className="vi-station-footer">
591
+ <div className="vi-station-footer-main">
592
+ {showGrid ? (
593
+ <div className="vi-frames-toolbar">
594
+ <span className="vi-frames-summary">
595
+ {frames.length} frame{frames.length === 1 ? "" : "s"} ·{" "}
596
+ {selectedCount} selected
597
+ </span>
598
+ <div className="vi-frames-actions">
599
+ <button
600
+ type="button"
601
+ className="vi-btn vi-btn-sm vi-btn-ghost"
602
+ onClick={selectAllOnPage}
603
+ >
604
+ Select page
605
+ </button>
606
+ <button
607
+ type="button"
608
+ className="vi-btn vi-btn-sm vi-btn-ghost"
609
+ onClick={clearSelection}
610
+ disabled={selectedCount === 0}
611
+ >
612
+ Clear
613
+ </button>
614
+ <button
615
+ type="button"
616
+ className="vi-btn vi-btn-sm vi-btn-accent"
617
+ onClick={addSelectedToGenerate}
618
+ disabled={selectedCount === 0}
619
+ >
620
+ Add {selectedCount} to Generate
621
+ </button>
622
+ </div>
623
+ </div>
624
+ ) : (
625
+ <p className="vi-crop-hint">
626
+ Set the options, then Extract to fill the strip above.
627
+ </p>
628
+ )}
629
+ </div>
630
+
631
+ <div className="vi-station-footer-run">
632
+ <div className="vi-frames-run">
633
+ <button
634
+ type="button"
635
+ className="vi-btn vi-btn-accent"
636
+ disabled={!canRun}
637
+ onClick={onRun}
638
+ >
639
+ {running ? "Extracting…" : "Extract frames"}
640
+ </button>
641
+ {status && (
642
+ <span className={`vi-status vi-status-${status}`}>{status}</span>
643
+ )}
644
+ </div>
645
+ {jobError && <p className="vi-error" role="alert">{jobError}</p>}
646
+ {capped && (
647
+ <div className="vi-frames-run">
648
+ <span className="vi-knob-flag">{STILL_RUNNING_MESSAGE}</span>
649
+ <button type="button" className="vi-btn vi-btn-sm" onClick={resume}>
650
+ Check again
651
+ </button>
652
+ </div>
653
+ )}
654
+ </div>
655
+ </div>
656
+
657
+ {/* ── CHARACTER TOOLS (t25) ──────────────────────────────────────────
658
+ A first-class group within the Frames station for character-centric
659
+ work on the loaded video / extracted frames. char360 view-set
660
+ extraction is its FIRST resident. This group is deliberately laid out
661
+ as a titled area so the pending character-aggregation modes (t19,
662
+ approved-pending: a during-processing toggle, an "aggregate now"
663
+ action, frame-multi-select→associate, a seek-identity picker) slot in
664
+ HERE as additional residents later — no aggregation is built now, just
665
+ the structural room. Rendered only when the source is a VIDEO (char360
666
+ operates on video); a still-frame source hides it honestly. */}
667
+ {sourceIsVideo && (
668
+ <section className="vi-character-tools" aria-label="Character tools">
669
+ <div className="vi-character-tools-head">
670
+ <span className="vi-comfy-label">Character tools</span>
671
+ <span className="vi-knob-hint" style={{ opacity: 0.7 }}>
672
+ Character-centric extraction over the loaded video.
673
+ </span>
674
+ </div>
675
+ {/* Resident 1 — char360 view-set extraction (reused wholesale). */}
676
+ <ExtractFromVideoPanel
677
+ profiles={idProfiles}
678
+ extractFromVideo={extractFromVideo}
679
+ reload={reloadIdentities}
680
+ presetVideo={source}
681
+ />
682
+ {/* Resident 2 (S2+S4) — char360 REVIEW + curate: non-committing extraction
683
+ → editable per-character groups → commit selected to identity
684
+ profiles → per-group "Generate 3D" (S4). Shares the SINGLE
685
+ useIdentityProfiles hook's extractFromVideo (never a second mount).
686
+ The panel owns its 3D job state internally; onGenerate3D is left
687
+ omitted — nothing outside /video/frames needs the notify hook. */}
688
+ <CharacterGroupsPanel source={source} extractFromVideo={extractFromVideo} />
689
+ </section>
690
+ )}
691
+ </div>
692
+ }
693
+ />
694
+ );
695
+ }