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