@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,198 @@
|
|
|
1
|
+
// Studio-movie TIMELINE MATH (slice B3) — the pure, JSX-free half of the spliced
|
|
2
|
+
// row. Given the terminal movie manifest's `segments` + `joints` (the honest
|
|
3
|
+
// non-destructive NLE ledger the runner writes; see studio_movie.py
|
|
4
|
+
// `_assemble_movie` / `_write_movie_json`), derive the strip the row draws:
|
|
5
|
+
// • each block's EFFECTIVE frames in the assembly — a PARENT trimmed at its
|
|
6
|
+
// child's splice contributes `trim_frames = branch_frame + 1`; the final
|
|
7
|
+
// (leaf) segment plays its FULL `frames`. This mirrors the runner's
|
|
8
|
+
// `contributions` list EXACTLY, so a block's width == its real share of the
|
|
9
|
+
// stitched movie.mp4 (never the whole untrimmed clip).
|
|
10
|
+
// • the splice markers that sit BETWEEN blocks — one per joint, labelled with
|
|
11
|
+
// the frame N of the parent the child diverged at.
|
|
12
|
+
//
|
|
13
|
+
// Kept pure + presentation-free so the width/joint math is inspectable on its own
|
|
14
|
+
// (the row component only lays these numbers out). Structural param types
|
|
15
|
+
// (`RowSegment`/`RowJoint`) are subsets of the composer's zod-parsed manifest
|
|
16
|
+
// shapes, so the composer passes its `ManifestSegment[]` / joints straight in.
|
|
17
|
+
|
|
18
|
+
/** A subset of the movie manifest's per-segment node the row reads (see
|
|
19
|
+
* StudioMovieComposer's `manifestSegmentSchema`). */
|
|
20
|
+
export interface RowSegment {
|
|
21
|
+
index: number;
|
|
22
|
+
segment_id?: string | null;
|
|
23
|
+
prompt?: string | null;
|
|
24
|
+
capability?: string | null;
|
|
25
|
+
branch_frame?: number | null;
|
|
26
|
+
resolved_branch?: number | null;
|
|
27
|
+
frames?: number | null;
|
|
28
|
+
width?: number | null;
|
|
29
|
+
height?: number | null;
|
|
30
|
+
duration_s?: number | null;
|
|
31
|
+
resumed?: boolean | null;
|
|
32
|
+
status?: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A subset of the manifest's per-joint trim record (see `manifestJointSchema`). */
|
|
36
|
+
export interface RowJoint {
|
|
37
|
+
parent_segment_id?: string | null;
|
|
38
|
+
child_segment_id?: string | null;
|
|
39
|
+
branch_frame?: number | null;
|
|
40
|
+
trim_frames?: number | null;
|
|
41
|
+
/** Splice mode the runner recorded: "still" | "vace_extend" | "cut". A "cut" carries no
|
|
42
|
+
* frame (branch_frame null, trim_frames == the parent's full length). */
|
|
43
|
+
mode?: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One laid-out block of the spliced row. */
|
|
47
|
+
export interface RowBlock {
|
|
48
|
+
index: number;
|
|
49
|
+
segmentId: string | null;
|
|
50
|
+
/** Frames this segment contributes to the assembled movie (trimmed or full). */
|
|
51
|
+
effectiveFrames: number;
|
|
52
|
+
/** The clip's real (untrimmed) frame count — the scrubber's slider bound. */
|
|
53
|
+
fullFrames: number | null;
|
|
54
|
+
/** Share of the assembled row, 0..100 (sums to ~100 across blocks). */
|
|
55
|
+
widthPct: number;
|
|
56
|
+
/** True when this parent is trimmed at a child's splice (a real cut happened). */
|
|
57
|
+
trimmed: boolean;
|
|
58
|
+
prompt: string | null;
|
|
59
|
+
capability: string | null;
|
|
60
|
+
status: string | null;
|
|
61
|
+
resumed: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A splice marker sitting AFTER `afterIndex` (between two blocks). */
|
|
65
|
+
export interface RowSplice {
|
|
66
|
+
afterIndex: number;
|
|
67
|
+
childIndex: number;
|
|
68
|
+
/** Frame N of the parent the child was conditioned on. null for a "cut" (no frame carry). */
|
|
69
|
+
branchFrame: number | null;
|
|
70
|
+
/** Frames of the parent kept in the assembly = branchFrame + 1 (the parent's FULL length
|
|
71
|
+
* for a "cut", which trims nothing). */
|
|
72
|
+
trimFrames: number | null;
|
|
73
|
+
/** Splice mode: "still" | "vace_extend" | "cut" — drives the notch label + tooltip. */
|
|
74
|
+
mode: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface DerivedRow {
|
|
78
|
+
blocks: RowBlock[];
|
|
79
|
+
splices: RowSplice[];
|
|
80
|
+
totalEffectiveFrames: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function num(v: number | null | undefined): number | null {
|
|
84
|
+
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Derive the spliced-row layout from the manifest's segments + joints.
|
|
89
|
+
*
|
|
90
|
+
* Effective frames per block (mirrors runners/studio_movie.py `_assemble_movie`):
|
|
91
|
+
* • a segment that is a JOINT PARENT keeps `trim_frames` (= branch_frame + 1);
|
|
92
|
+
* • the leaf/last segment keeps its FULL `frames`.
|
|
93
|
+
* Widths are proportional to those effective counts. When no honest frame count
|
|
94
|
+
* is available (e.g. a manifest without `frames`), blocks fall back to EQUAL
|
|
95
|
+
* widths so the row still reads as a strip rather than collapsing.
|
|
96
|
+
*/
|
|
97
|
+
export function deriveRow(segments: RowSegment[], joints: RowJoint[]): DerivedRow {
|
|
98
|
+
const segs = [...segments].sort((a, b) => a.index - b.index);
|
|
99
|
+
|
|
100
|
+
// Trim lookup keyed by the joint's PARENT segment id (linear chain: one child
|
|
101
|
+
// per parent). Also index each joint's child position for splice placement.
|
|
102
|
+
const jointByParent = new Map<string, RowJoint>();
|
|
103
|
+
for (const j of joints) {
|
|
104
|
+
if (typeof j.parent_segment_id === "string") jointByParent.set(j.parent_segment_id, j);
|
|
105
|
+
}
|
|
106
|
+
const indexById = new Map<string, number>();
|
|
107
|
+
segs.forEach((s, i) => {
|
|
108
|
+
if (typeof s.segment_id === "string") indexById.set(s.segment_id, i);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const raw: number[] = segs.map((s) => {
|
|
112
|
+
const joint = typeof s.segment_id === "string" ? jointByParent.get(s.segment_id) : undefined;
|
|
113
|
+
const trim = num(joint?.trim_frames);
|
|
114
|
+
if (trim != null && trim > 0) return trim;
|
|
115
|
+
const full = num(s.frames);
|
|
116
|
+
return full != null && full > 0 ? full : 0;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
let total = raw.reduce((a, b) => a + b, 0);
|
|
120
|
+
// Fallback: no honest counts anywhere → equal widths (still a legible strip).
|
|
121
|
+
const equal = total <= 0;
|
|
122
|
+
if (equal) total = segs.length || 1;
|
|
123
|
+
|
|
124
|
+
const blocks: RowBlock[] = segs.map((s, i) => {
|
|
125
|
+
const eff = equal ? 1 : raw[i];
|
|
126
|
+
const joint = typeof s.segment_id === "string" ? jointByParent.get(s.segment_id) : undefined;
|
|
127
|
+
return {
|
|
128
|
+
index: s.index,
|
|
129
|
+
segmentId: typeof s.segment_id === "string" ? s.segment_id : null,
|
|
130
|
+
effectiveFrames: eff,
|
|
131
|
+
fullFrames: num(s.frames),
|
|
132
|
+
widthPct: (eff / total) * 100,
|
|
133
|
+
// A "cut" parent plays in FULL (trim_frames == full) — it is NOT trimmed, so the
|
|
134
|
+
// block shows no ✂ trim badge (the CUT is drawn as the splice notch below instead).
|
|
135
|
+
trimmed: !!joint && joint.mode !== "cut" && num(joint.trim_frames) != null,
|
|
136
|
+
prompt: typeof s.prompt === "string" ? s.prompt : null,
|
|
137
|
+
capability: typeof s.capability === "string" ? s.capability : null,
|
|
138
|
+
status: typeof s.status === "string" ? s.status : null,
|
|
139
|
+
resumed: s.resumed === true,
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const splices: RowSplice[] = [];
|
|
144
|
+
for (const j of joints) {
|
|
145
|
+
const parentId = j.parent_segment_id;
|
|
146
|
+
const childId = j.child_segment_id;
|
|
147
|
+
if (typeof parentId !== "string" || typeof childId !== "string") continue;
|
|
148
|
+
const afterIndex = indexById.get(parentId);
|
|
149
|
+
const childIndex = indexById.get(childId);
|
|
150
|
+
if (afterIndex == null || childIndex == null) continue;
|
|
151
|
+
const mode = typeof j.mode === "string" ? j.mode : "still";
|
|
152
|
+
const branchFrame = num(j.branch_frame);
|
|
153
|
+
const trimFrames = num(j.trim_frames);
|
|
154
|
+
// A "cut" carries NO frame (branch_frame null) but STILL draws a notch — a hard scene
|
|
155
|
+
// cut. Every other splice needs a real branch frame (a null is a malformed record).
|
|
156
|
+
if (mode === "cut") {
|
|
157
|
+
splices.push({ afterIndex, childIndex, branchFrame: null, trimFrames, mode: "cut" });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (branchFrame == null) continue;
|
|
161
|
+
splices.push({
|
|
162
|
+
afterIndex,
|
|
163
|
+
childIndex,
|
|
164
|
+
branchFrame,
|
|
165
|
+
trimFrames: trimFrames ?? branchFrame + 1,
|
|
166
|
+
mode,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
splices.sort((a, b) => a.afterIndex - b.afterIndex);
|
|
170
|
+
|
|
171
|
+
return { blocks, splices, totalEffectiveFrames: total };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Movie fps: the manifest value when present, else the composer's knob fallback. */
|
|
175
|
+
export function resolveFps(movieFps: number | null | undefined, fallback: number): number {
|
|
176
|
+
const f = num(movieFps ?? null);
|
|
177
|
+
return f != null && f > 0 ? f : fallback;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Clamp a frame index into a clip's [0, frames-1] range. */
|
|
181
|
+
export function clampFrame(frame: number, frames: number | null | undefined): number {
|
|
182
|
+
const max = num(frames ?? null);
|
|
183
|
+
const hi = max != null && max > 0 ? max - 1 : 0;
|
|
184
|
+
if (!Number.isFinite(frame)) return 0;
|
|
185
|
+
return Math.max(0, Math.min(hi, Math.round(frame)));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Seconds a frame index lands at, for seeking a <video> (frame / fps). */
|
|
189
|
+
export function frameToTime(frame: number, fps: number): number {
|
|
190
|
+
const f = fps > 0 ? fps : 1;
|
|
191
|
+
return frame / f;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Nearest frame index for a <video>'s currentTime (round to the grid). */
|
|
195
|
+
export function timeToFrame(time: number, fps: number): number {
|
|
196
|
+
const f = fps > 0 ? fps : 1;
|
|
197
|
+
return Math.round(time * f);
|
|
198
|
+
}
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
// SHARED PROMPT-CARD SYSTEM (task k88 — Studio declutter, part 1 of 2).
|
|
2
|
+
//
|
|
3
|
+
// ONE uniform structure for every Studio prompt list (Cinema + Movie now; Scene
|
|
4
|
+
// parts + Clip in the follow-up task k89):
|
|
5
|
+
//
|
|
6
|
+
// toolbar: [# count][select all][✨ Generate prompts][✨ Generate negatives][standard negatives]
|
|
7
|
+
// row card: [badge #N] [↑][↓][Split][✕]
|
|
8
|
+
// [Prompt][Negative] tabs over ONE editor pane + [standard negative] tick
|
|
9
|
+
// <GoalComposer pane for the active tab>
|
|
10
|
+
// …host body (Prompt Assist row, attach rows)…
|
|
11
|
+
// [settings ▸] expander hosting the row's tab-specific knobs
|
|
12
|
+
//
|
|
13
|
+
// Everything here is PRESENTATIONAL in the GoalComposer/PromptAssistButtons sense:
|
|
14
|
+
// no component owns editor state — values come in and callbacks go out, so each
|
|
15
|
+
// tab keeps its own immutable map/filter state model. The ONLY internal state is
|
|
16
|
+
// ephemeral VIEW state (which tab is showing, whether the settings expander is
|
|
17
|
+
// open — the latter via a native <details>), which is never data the host reads.
|
|
18
|
+
//
|
|
19
|
+
// Glyph ruling (one set everywhere): ↑ ↓ ✕ + a text "Split" — the Scene/Movie
|
|
20
|
+
// set. Cinema's ▲▼ are retired by its migration onto PromptCard.
|
|
21
|
+
import { useState, type ReactNode } from "react";
|
|
22
|
+
import { GoalComposer } from "../GoalComposer";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The STANDARD negative set — the text GenerateStation's negative field opens
|
|
26
|
+
* pre-filled with (operator 2026-07-13: distilled/painterly checkpoints reproduce
|
|
27
|
+
* whole "framed gallery painting" objects from their training prior; suppressing
|
|
28
|
+
* the frame + the usual junk by default makes the happy path a clean image).
|
|
29
|
+
* MOVED here from GenerateStation's module-private DEFAULT_NEGATIVE so the
|
|
30
|
+
* per-row [standard negative] checkbox and the station prefill read the same
|
|
31
|
+
* source; GenerateStation imports it back (no behavior change there).
|
|
32
|
+
*/
|
|
33
|
+
export const STANDARD_NEGATIVE =
|
|
34
|
+
"frame, picture frame, ornate frame, border, framed painting, gallery wall, watermark, signature, text";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The effective negative for one row: the user's own negative, with the standard
|
|
38
|
+
* set APPENDED (comma-joined, whitespace-normalized, term-deduped) when the row's
|
|
39
|
+
* [standard negative] checkbox is on. Called ONLY at the point a request payload
|
|
40
|
+
* is built — the wire schemas are untouched; composition is a client-side fold.
|
|
41
|
+
*/
|
|
42
|
+
export function composeNegative(negative: string, stdNegative: boolean): string {
|
|
43
|
+
const user = negative.trim();
|
|
44
|
+
if (!stdNegative) return user;
|
|
45
|
+
const seen = new Set<string>();
|
|
46
|
+
const terms: string[] = [];
|
|
47
|
+
for (const raw of `${user},${STANDARD_NEGATIVE}`.split(",")) {
|
|
48
|
+
const term = raw.trim().replace(/\s+/g, " ");
|
|
49
|
+
if (term === "") continue;
|
|
50
|
+
const dedupeKey = term.toLowerCase();
|
|
51
|
+
if (seen.has(dedupeKey)) continue;
|
|
52
|
+
seen.add(dedupeKey);
|
|
53
|
+
terms.push(term);
|
|
54
|
+
}
|
|
55
|
+
return terms.join(", ");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── PromptListToolbar ───────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/** The optional "how many rows" count input at the toolbar's left edge. */
|
|
61
|
+
export interface PromptListToolbarCount {
|
|
62
|
+
id: string;
|
|
63
|
+
/** Knob label — "Segments" / "Goals". */
|
|
64
|
+
label: string;
|
|
65
|
+
value: number;
|
|
66
|
+
min?: number;
|
|
67
|
+
max: number;
|
|
68
|
+
onChange: (n: number) => void;
|
|
69
|
+
title?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface PromptListToolbarProps {
|
|
73
|
+
/** Omit to render the toolbar without the count knob. */
|
|
74
|
+
count?: PromptListToolbarCount;
|
|
75
|
+
/** "segment" / "goal" — used in aria labels only. */
|
|
76
|
+
noun: string;
|
|
77
|
+
selectedCount: number;
|
|
78
|
+
itemCount: number;
|
|
79
|
+
allSelected: boolean;
|
|
80
|
+
onToggleSelectAll: () => void;
|
|
81
|
+
/** Full button text, busy/progress label included — the host owns the wording. */
|
|
82
|
+
generateLabel: string;
|
|
83
|
+
generateTitle?: string;
|
|
84
|
+
onGenerate: () => void;
|
|
85
|
+
generateDisabledTitle?: string;
|
|
86
|
+
negativesLabel: string;
|
|
87
|
+
negativesTitle?: string;
|
|
88
|
+
onNegatives: () => void;
|
|
89
|
+
/** True while ANY assist call is in flight — disables both group actions. */
|
|
90
|
+
assistBusy?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* Tick the [standard negative] checkbox ON for the selected rows (or every
|
|
93
|
+
* row when nothing is selected) — the host implements that rule.
|
|
94
|
+
*/
|
|
95
|
+
onStandardNegatives: () => void;
|
|
96
|
+
standardNegativesTitle?: string;
|
|
97
|
+
disabled?: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function PromptListToolbar({
|
|
101
|
+
count,
|
|
102
|
+
noun,
|
|
103
|
+
selectedCount,
|
|
104
|
+
itemCount,
|
|
105
|
+
allSelected,
|
|
106
|
+
onToggleSelectAll,
|
|
107
|
+
generateLabel,
|
|
108
|
+
generateTitle,
|
|
109
|
+
onGenerate,
|
|
110
|
+
generateDisabledTitle,
|
|
111
|
+
negativesLabel,
|
|
112
|
+
negativesTitle,
|
|
113
|
+
onNegatives,
|
|
114
|
+
assistBusy = false,
|
|
115
|
+
onStandardNegatives,
|
|
116
|
+
standardNegativesTitle,
|
|
117
|
+
disabled = false,
|
|
118
|
+
}: PromptListToolbarProps) {
|
|
119
|
+
const actionsDisabled = disabled || assistBusy || selectedCount === 0;
|
|
120
|
+
return (
|
|
121
|
+
<div
|
|
122
|
+
className="vi-prompt-toolbar"
|
|
123
|
+
role="group"
|
|
124
|
+
aria-label={`${noun} count and group prompt assist`}
|
|
125
|
+
>
|
|
126
|
+
{count && (
|
|
127
|
+
<div className="vi-knob vi-prompt-toolbar-count">
|
|
128
|
+
<label htmlFor={count.id}>{count.label}</label>
|
|
129
|
+
<input
|
|
130
|
+
id={count.id}
|
|
131
|
+
className="vi-knob-input"
|
|
132
|
+
type="number"
|
|
133
|
+
min={count.min ?? 1}
|
|
134
|
+
max={count.max}
|
|
135
|
+
value={count.value}
|
|
136
|
+
disabled={disabled}
|
|
137
|
+
title={count.title}
|
|
138
|
+
onChange={(e) => count.onChange(Number(e.target.value))}
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
142
|
+
<label className="vi-comfy-hint vi-prompt-toolbar-selectall">
|
|
143
|
+
<input
|
|
144
|
+
type="checkbox"
|
|
145
|
+
checked={allSelected}
|
|
146
|
+
disabled={disabled || itemCount === 0}
|
|
147
|
+
onChange={onToggleSelectAll}
|
|
148
|
+
aria-label={`Select every ${noun}`}
|
|
149
|
+
/>
|
|
150
|
+
Select all ({selectedCount}/{itemCount})
|
|
151
|
+
</label>
|
|
152
|
+
<button
|
|
153
|
+
type="button"
|
|
154
|
+
className="vi-btn vi-btn-sm vi-btn-accent"
|
|
155
|
+
disabled={actionsDisabled}
|
|
156
|
+
title={selectedCount === 0 ? (generateDisabledTitle ?? generateTitle) : generateTitle}
|
|
157
|
+
onClick={onGenerate}
|
|
158
|
+
>
|
|
159
|
+
{generateLabel}
|
|
160
|
+
</button>
|
|
161
|
+
<button
|
|
162
|
+
type="button"
|
|
163
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
164
|
+
disabled={actionsDisabled}
|
|
165
|
+
title={negativesTitle}
|
|
166
|
+
onClick={onNegatives}
|
|
167
|
+
>
|
|
168
|
+
{negativesLabel}
|
|
169
|
+
</button>
|
|
170
|
+
<button
|
|
171
|
+
type="button"
|
|
172
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
173
|
+
disabled={disabled || itemCount === 0}
|
|
174
|
+
title={
|
|
175
|
+
standardNegativesTitle ??
|
|
176
|
+
"Tick the [standard negative] checkbox on for the selected rows (or every row when none are selected)."
|
|
177
|
+
}
|
|
178
|
+
onClick={onStandardNegatives}
|
|
179
|
+
>
|
|
180
|
+
standard negatives
|
|
181
|
+
</button>
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── PromptCard (the row shell) ──────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
/** The optional leading select-checkbox slot (group-assist selection). */
|
|
189
|
+
export interface PromptCardSelect {
|
|
190
|
+
checked: boolean;
|
|
191
|
+
onChange: () => void;
|
|
192
|
+
disabled?: boolean;
|
|
193
|
+
title?: string;
|
|
194
|
+
/** aria-label; defaults to "Select <noun> N". */
|
|
195
|
+
label?: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface PromptCardProps {
|
|
199
|
+
/** React's list key — consumed by React, never forwarded. Declared because this
|
|
200
|
+
repo type-checks JSX attributes against props with no React types installed. */
|
|
201
|
+
key?: string;
|
|
202
|
+
/** Identifier badge text — "segment" / "goal". */
|
|
203
|
+
badge: string;
|
|
204
|
+
/** 0-based row position; rendered as "#N+1". */
|
|
205
|
+
index: number;
|
|
206
|
+
/** "segment" / "goal" — aria labels + button titles. */
|
|
207
|
+
noun: string;
|
|
208
|
+
select?: PromptCardSelect;
|
|
209
|
+
/** Extra header content between the index and the action cluster (joint descriptor …). */
|
|
210
|
+
headerHint?: ReactNode;
|
|
211
|
+
/** Move/Remove render ONLY when their callback is given (k89: Clip's single card
|
|
212
|
+
passes none, so its action cluster is empty rather than three dead buttons). */
|
|
213
|
+
canMoveUp?: boolean;
|
|
214
|
+
canMoveDown?: boolean;
|
|
215
|
+
onMoveUp?: () => void;
|
|
216
|
+
onMoveDown?: () => void;
|
|
217
|
+
moveUpTitle?: string;
|
|
218
|
+
moveDownTitle?: string;
|
|
219
|
+
/** Split renders ONLY when this is given (Movie has it, Cinema does not). */
|
|
220
|
+
onSplit?: () => void;
|
|
221
|
+
splitTitle?: string;
|
|
222
|
+
canRemove?: boolean;
|
|
223
|
+
onRemove?: () => void;
|
|
224
|
+
removeTitle?: string;
|
|
225
|
+
disabled?: boolean;
|
|
226
|
+
children: ReactNode;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function PromptCard({
|
|
230
|
+
badge,
|
|
231
|
+
index,
|
|
232
|
+
noun,
|
|
233
|
+
select,
|
|
234
|
+
headerHint,
|
|
235
|
+
canMoveUp,
|
|
236
|
+
canMoveDown,
|
|
237
|
+
onMoveUp,
|
|
238
|
+
onMoveDown,
|
|
239
|
+
moveUpTitle,
|
|
240
|
+
moveDownTitle,
|
|
241
|
+
onSplit,
|
|
242
|
+
splitTitle,
|
|
243
|
+
canRemove,
|
|
244
|
+
onRemove,
|
|
245
|
+
removeTitle,
|
|
246
|
+
disabled = false,
|
|
247
|
+
children,
|
|
248
|
+
}: PromptCardProps) {
|
|
249
|
+
return (
|
|
250
|
+
<li className="vi-gen-part vi-movie-goal vi-prompt-card">
|
|
251
|
+
<div className="vi-gen-part-head">
|
|
252
|
+
{select && (
|
|
253
|
+
<input
|
|
254
|
+
type="checkbox"
|
|
255
|
+
checked={select.checked}
|
|
256
|
+
disabled={disabled || select.disabled}
|
|
257
|
+
onChange={select.onChange}
|
|
258
|
+
aria-label={select.label ?? `Select ${noun} ${index + 1}`}
|
|
259
|
+
title={select.title}
|
|
260
|
+
/>
|
|
261
|
+
)}
|
|
262
|
+
<span className="vi-gen-badge vi-movie-badge">{badge}</span>
|
|
263
|
+
<span className="vi-gen-part-idx">#{index + 1}</span>
|
|
264
|
+
{headerHint}
|
|
265
|
+
{(onMoveUp || onMoveDown || onSplit || onRemove) && (
|
|
266
|
+
<div className="vi-gen-part-actions">
|
|
267
|
+
{onMoveUp && (
|
|
268
|
+
<button
|
|
269
|
+
type="button"
|
|
270
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
271
|
+
disabled={disabled || !canMoveUp}
|
|
272
|
+
onClick={onMoveUp}
|
|
273
|
+
title={moveUpTitle ?? "Move earlier"}
|
|
274
|
+
aria-label={`Move ${noun} earlier`}
|
|
275
|
+
>
|
|
276
|
+
↑
|
|
277
|
+
</button>
|
|
278
|
+
)}
|
|
279
|
+
{onMoveDown && (
|
|
280
|
+
<button
|
|
281
|
+
type="button"
|
|
282
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
283
|
+
disabled={disabled || !canMoveDown}
|
|
284
|
+
onClick={onMoveDown}
|
|
285
|
+
title={moveDownTitle ?? "Move later"}
|
|
286
|
+
aria-label={`Move ${noun} later`}
|
|
287
|
+
>
|
|
288
|
+
↓
|
|
289
|
+
</button>
|
|
290
|
+
)}
|
|
291
|
+
{onSplit && (
|
|
292
|
+
<button
|
|
293
|
+
type="button"
|
|
294
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
295
|
+
disabled={disabled}
|
|
296
|
+
onClick={onSplit}
|
|
297
|
+
title={splitTitle ?? `Split this ${noun}`}
|
|
298
|
+
aria-label={`Split ${noun}`}
|
|
299
|
+
>
|
|
300
|
+
Split
|
|
301
|
+
</button>
|
|
302
|
+
)}
|
|
303
|
+
{onRemove && (
|
|
304
|
+
<button
|
|
305
|
+
type="button"
|
|
306
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
307
|
+
disabled={disabled || !canRemove}
|
|
308
|
+
onClick={onRemove}
|
|
309
|
+
title={removeTitle ?? `Remove ${noun}`}
|
|
310
|
+
aria-label={`Remove ${noun}`}
|
|
311
|
+
>
|
|
312
|
+
✕
|
|
313
|
+
</button>
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
)}
|
|
317
|
+
</div>
|
|
318
|
+
{children}
|
|
319
|
+
</li>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── PromptFieldTabs ─────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
export interface PromptFieldTabsProps {
|
|
326
|
+
/** DOM id namespace — the prompt pane is `${idPrefix}-prompt`, the negative `${idPrefix}-negative`. */
|
|
327
|
+
idPrefix: string;
|
|
328
|
+
promptLabel: string;
|
|
329
|
+
prompt: string;
|
|
330
|
+
onPromptChange: (v: string) => void;
|
|
331
|
+
promptPlaceholder?: string;
|
|
332
|
+
/** Blur hook for the prompt pane (Cinema's intent router classifies here). */
|
|
333
|
+
onPromptBlur?: () => void;
|
|
334
|
+
negativeLabel?: string;
|
|
335
|
+
negative: string;
|
|
336
|
+
onNegativeChange: (v: string) => void;
|
|
337
|
+
negativePlaceholder?: string;
|
|
338
|
+
negativeHint?: ReactNode;
|
|
339
|
+
stdNegative: boolean;
|
|
340
|
+
onStdNegativeChange: (v: boolean) => void;
|
|
341
|
+
/**
|
|
342
|
+
* When set, the Negative tab + the standard-negative tick render DISABLED with
|
|
343
|
+
* this text as the tooltip (Movie: the wire carries ONE negative — row 0 only).
|
|
344
|
+
*/
|
|
345
|
+
negativeDisabledNote?: string;
|
|
346
|
+
rows?: number;
|
|
347
|
+
disabled?: boolean;
|
|
348
|
+
/** Rendered under the negative pane when it is active ("✨ Generate negative" row). */
|
|
349
|
+
negativeExtras?: ReactNode;
|
|
350
|
+
/** Rendered under the prompt pane when it is active. */
|
|
351
|
+
promptExtras?: ReactNode;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function PromptFieldTabs({
|
|
355
|
+
idPrefix,
|
|
356
|
+
promptLabel,
|
|
357
|
+
prompt,
|
|
358
|
+
onPromptChange,
|
|
359
|
+
promptPlaceholder,
|
|
360
|
+
onPromptBlur,
|
|
361
|
+
negativeLabel = "negative prompt (optional)",
|
|
362
|
+
negative,
|
|
363
|
+
onNegativeChange,
|
|
364
|
+
negativePlaceholder,
|
|
365
|
+
negativeHint,
|
|
366
|
+
stdNegative,
|
|
367
|
+
onStdNegativeChange,
|
|
368
|
+
negativeDisabledNote,
|
|
369
|
+
rows = 2,
|
|
370
|
+
disabled = false,
|
|
371
|
+
negativeExtras,
|
|
372
|
+
promptExtras,
|
|
373
|
+
}: PromptFieldTabsProps) {
|
|
374
|
+
// Ephemeral VIEW state only — which pane shows. All editor data rides props.
|
|
375
|
+
const [tab, setTab] = useState<"prompt" | "negative">("prompt");
|
|
376
|
+
const negativeLocked = negativeDisabledNote != null;
|
|
377
|
+
const active = negativeLocked ? "prompt" : tab;
|
|
378
|
+
return (
|
|
379
|
+
<div className="vi-prompt-tabbed">
|
|
380
|
+
<div className="vi-prompt-tabs" role="tablist" aria-label="Prompt or negative prompt">
|
|
381
|
+
<button
|
|
382
|
+
type="button"
|
|
383
|
+
role="tab"
|
|
384
|
+
className="vi-prompt-tab"
|
|
385
|
+
aria-selected={active === "prompt"}
|
|
386
|
+
onClick={() => setTab("prompt")}
|
|
387
|
+
>
|
|
388
|
+
Prompt
|
|
389
|
+
</button>
|
|
390
|
+
<button
|
|
391
|
+
type="button"
|
|
392
|
+
role="tab"
|
|
393
|
+
className="vi-prompt-tab"
|
|
394
|
+
aria-selected={active === "negative"}
|
|
395
|
+
disabled={negativeLocked}
|
|
396
|
+
title={negativeDisabledNote}
|
|
397
|
+
onClick={() => setTab("negative")}
|
|
398
|
+
>
|
|
399
|
+
Negative
|
|
400
|
+
{/* The content marker — tab state must not hide a written negative. */}
|
|
401
|
+
{negative.trim() !== "" && <span className="vi-prompt-tab-dot" aria-label="has content" />}
|
|
402
|
+
</button>
|
|
403
|
+
<label
|
|
404
|
+
className="vi-comfy-hint vi-prompt-tab-std"
|
|
405
|
+
title={
|
|
406
|
+
negativeDisabledNote ??
|
|
407
|
+
"Append the standard artifact/junk exclusion set to this row's negative when the request is built."
|
|
408
|
+
}
|
|
409
|
+
>
|
|
410
|
+
<input
|
|
411
|
+
type="checkbox"
|
|
412
|
+
checked={stdNegative}
|
|
413
|
+
disabled={disabled || negativeLocked}
|
|
414
|
+
onChange={(e) => onStdNegativeChange(e.target.checked)}
|
|
415
|
+
/>
|
|
416
|
+
standard negative
|
|
417
|
+
</label>
|
|
418
|
+
</div>
|
|
419
|
+
{active === "prompt" ? (
|
|
420
|
+
<>
|
|
421
|
+
<GoalComposer
|
|
422
|
+
id={`${idPrefix}-prompt`}
|
|
423
|
+
label={promptLabel}
|
|
424
|
+
value={prompt}
|
|
425
|
+
placeholder={promptPlaceholder}
|
|
426
|
+
onChange={onPromptChange}
|
|
427
|
+
onBlur={onPromptBlur}
|
|
428
|
+
rows={rows}
|
|
429
|
+
disabled={disabled}
|
|
430
|
+
/>
|
|
431
|
+
{promptExtras}
|
|
432
|
+
</>
|
|
433
|
+
) : (
|
|
434
|
+
<>
|
|
435
|
+
<GoalComposer
|
|
436
|
+
id={`${idPrefix}-negative`}
|
|
437
|
+
label={negativeLabel}
|
|
438
|
+
value={negative}
|
|
439
|
+
placeholder={negativePlaceholder}
|
|
440
|
+
onChange={onNegativeChange}
|
|
441
|
+
rows={rows}
|
|
442
|
+
disabled={disabled}
|
|
443
|
+
hint={negativeHint}
|
|
444
|
+
/>
|
|
445
|
+
{negativeExtras}
|
|
446
|
+
</>
|
|
447
|
+
)}
|
|
448
|
+
</div>
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ── PromptCardSettings ──────────────────────────────────────────────────────
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The labelled settings expander at a card's foot — collapsed by default, styled
|
|
456
|
+
* on the condensed knob-strip rhythm (see .vi-prompt-card-settings). A native
|
|
457
|
+
* <details>, so the open/closed flag lives in the DOM, not in anyone's state.
|
|
458
|
+
* Children are the row's tab-specific knobs (Cinema: seed/joint/branch; Movie:
|
|
459
|
+
* the frame window).
|
|
460
|
+
*/
|
|
461
|
+
export function PromptCardSettings({
|
|
462
|
+
label = "settings",
|
|
463
|
+
children,
|
|
464
|
+
}: {
|
|
465
|
+
label?: string;
|
|
466
|
+
children: ReactNode;
|
|
467
|
+
}) {
|
|
468
|
+
return (
|
|
469
|
+
<details className="vi-prompt-card-settings">
|
|
470
|
+
<summary className="vi-prompt-card-settings-summary">{label}</summary>
|
|
471
|
+
<div className="vi-prompt-card-settings-body">{children}</div>
|
|
472
|
+
</details>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ── AboutExpander ───────────────────────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* The sidebar "About <tab>" expander (k89) — the condensed what-is-this / how-to
|
|
480
|
+
* prose each Studio tab used to carry inline, folded into ONE collapsed-by-default
|
|
481
|
+
* <details> at the top of that tab's Settings-tab content. Same native-<details>
|
|
482
|
+
* rule as PromptCardSettings: the open flag lives in the DOM, never in state.
|
|
483
|
+
* Children are short lines (<p>/<span>) — the body stacks them.
|
|
484
|
+
*/
|
|
485
|
+
export function AboutExpander({
|
|
486
|
+
title,
|
|
487
|
+
children,
|
|
488
|
+
}: {
|
|
489
|
+
title: string;
|
|
490
|
+
children: ReactNode;
|
|
491
|
+
}) {
|
|
492
|
+
return (
|
|
493
|
+
<details className="vi-about-expander">
|
|
494
|
+
<summary className="vi-about-summary">{title}</summary>
|
|
495
|
+
<div className="vi-about-body">{children}</div>
|
|
496
|
+
</details>
|
|
497
|
+
);
|
|
498
|
+
}
|