@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,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 < 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
|
+
}
|