@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,412 @@
1
+ // GENERATION-WIDE IDENTITY β€” the compact per-part affordance the Image/Scene
2
+ // prompt composer embeds next to "+ attach image" / "+ attach video".
3
+ //
4
+ // DOCTRINE (operator 2026-07-16): "if an identity exists, it can be selected and
5
+ // referenced per that prompt for the character association expected within the
6
+ // generation." Identity is ONE per generation, NOT one per part β€” a profile IS the
7
+ // whole identity (the same replace-semantics IdentityProfileControls encodes for
8
+ // Studio). Every part therefore renders the SAME bound identity and any part can
9
+ // bind/clear it; the copy says "this generation" (never "this part") so the user is
10
+ // never misled into thinking part #2 holds a different character than part #1.
11
+ //
12
+ // ONE CARRYING VIEW (operator 2026-07-16, second correction): "selecting an identity
13
+ // produces a row of canonical images for that identity in which the user selects one
14
+ // that is to be the carrying identity for that prompt."
15
+ //
16
+ // WHY EXACTLY ONE β€” this is a TRUTHFULNESS fix, not a preference. The generate runner
17
+ // consumes only the FIRST image part:
18
+ // abstract_hugpy_dev/src/abstract_hugpy_dev/video_intel/runners/imagegen.py:117
19
+ // start_frame = image_paths[0] if image_paths else None
20
+ // (scene.py:539 does the same via next(...)). Images 2..N are collected, counted in
21
+ // a log line, and NEVER used. The previous "bind attaches every canonical view" flow
22
+ // therefore attached 4 parts of which 3 were decorative β€” the UI implied a multi-view
23
+ // conditioning that does not exist. So the user now PICKS the single view that will
24
+ // actually carry, and the copy says why. If the runner ever learns to consume real
25
+ // multi-view conditioning, this becomes a multi-select β€” until then, one.
26
+ //
27
+ // PER-VIEW ANGLE LABELLING β€” verified against the live payload (GET
28
+ // http://127.0.0.1:7002/video/identity-profiles), both cases in the SAME response:
29
+ // * Luigi: canonical=8, canonical_angles=[0.0, 45.0, 90.0, ..., 315.0] β€” every
30
+ // view now has real azimuth provenance (identity_profiles.py's backfill).
31
+ // * sailor-moon / keeper-mesh-live / video-char-*: canonical=4,
32
+ // canonical_angles ABSENT (key omitted entirely β€” a pre-backfill profile).
33
+ // `profile.canonical` is still a FLAT LIST OF PATH STRINGS; `canonical_angles`,
34
+ // when present, is POSITIONALLY ALIGNED to it (canonical_angles[i] describes
35
+ // canonical[i]) β€” additive, never required. The key is omitted entirely when
36
+ // unknown for the WHOLE profile; an individual element may be `null` when
37
+ // unknown for just that view. On a length mismatch the backend itself drops the
38
+ // key rather than mislabel β€” `viewLabel` below re-checks the lengths anyway
39
+ // (never trust the wire honored its own contract). `azimuth_deg` is canonical
40
+ // truth (identity_profiles.py:1556) β€” degrees win over any semantic name on
41
+ // disagreement, so names below are ALWAYS derived from the degree, never the
42
+ // reverse. When the angle for a specific view is absent/null, THAT view falls
43
+ // back to the honest ordinal ("view N") exactly as every view did before this
44
+ // slice β€” never guessed, never inferred from array index.
45
+ //
46
+ // WHY A SLIM SIBLING, NOT ./studio/IdentityProfileControls:
47
+ // 1. That component calls useIdentityProfiles() ITSELF. Mounting it once per part
48
+ // would fire N duplicate GET /video/identity-profiles and hold N independently
49
+ // stale lists (the hook has no cross-instance cache). This component is PURE β€”
50
+ // the profile list + callbacks arrive as props from ONE hook at the station.
51
+ // 2. Its Save/Archive verbs are identity-library management; per prompt part the
52
+ // only sensible verbs are bind + clear (managing profiles lives in the
53
+ // Identities station).
54
+ // 3. It is a spacious Studio-sized block (inline styles, a 12rem scrolling panel);
55
+ // the vi-gen-part card family is compact.
56
+ // IdentityProfileControls is UNTOUCHED and still used by Studio β€” nothing superseded.
57
+ import { useState } from "react";
58
+ import { mediaBytesUrl } from "../config";
59
+ import type { IdentityProfile } from "./studio/useIdentityProfiles";
60
+
61
+ // Mirrors the backend's SEMANTIC_VIEWS (identity_profiles.py) β€” a convenience
62
+ // display name over the canonical degree, NEVER the source of truth. Degrees win
63
+ // on disagreement: this map only ever picks the NEAREST name to a real
64
+ // azimuth_deg, it never produces a degree itself.
65
+ // HANDEDNESS RESOLVED 2026-07-16 β€” operator eyeballed the labeled rows on two textured
66
+ // identities: "0 is front and 90 degrees is stage left". Stage left == the performer's
67
+ // left == the camera's right, so at 90Β° the camera sees the subject's own LEFT side.
68
+ // The sided names are mirrored from the original never-eyeballed guess (45<->315,
69
+ // 90<->270, 135<->225); 0Β°/180Β° are the mirror's fixed points. Kept in lockstep with
70
+ // the backend's SEMANTIC_VIEWS (identity_profiles.py) β€” if these two ever disagree the
71
+ // backend wins, and the DEGREES win over both.
72
+ const SEMANTIC_VIEWS: ReadonlyArray<readonly [number, string]> = [
73
+ [0, "front"],
74
+ [45, "ΒΎ left"],
75
+ [90, "left profile"],
76
+ [135, "back-left"],
77
+ [180, "back"],
78
+ [225, "back-right"],
79
+ [270, "right profile"],
80
+ [315, "ΒΎ right"],
81
+ ];
82
+
83
+ /** Nearest semantic name for a real azimuth degree (wraps 0/360). */
84
+ function semanticNameFor(deg: number): string {
85
+ const norm = ((deg % 360) + 360) % 360;
86
+ let best = SEMANTIC_VIEWS[0];
87
+ let bestDist = 360;
88
+ for (const entry of SEMANTIC_VIEWS) {
89
+ const [target] = entry;
90
+ const dist = Math.min(Math.abs(norm - target), 360 - Math.abs(norm - target));
91
+ if (dist < bestDist) {
92
+ bestDist = dist;
93
+ best = entry;
94
+ }
95
+ }
96
+ return best[1];
97
+ }
98
+
99
+ /** The profile bound as this generation's identity (mirrors studio's SelectedProfile). */
100
+ export interface BoundIdentity {
101
+ slug: string;
102
+ name: string;
103
+ /** Index into profile.canonical of the ONE view carrying the identity. */
104
+ viewIndex: number;
105
+ }
106
+
107
+ /** The real azimuth for canonical view #i, or null when unknown.
108
+ *
109
+ * Honest by construction: `canonical_angles` is optional and may be absent
110
+ * entirely (whole profile pre-backfill), shorter/longer than `canonical` (the
111
+ * UI's own length re-check β€” never trust the wire honored its own contract even
112
+ * though the backend is documented to drop the key itself on mismatch), or hold
113
+ * a `null` at this specific index (unknown for just that view). Any of those
114
+ * yields null here, never a guessed 0. */
115
+ function angleFor(profile: IdentityProfile | null, index: number): number | null {
116
+ if (!profile) return null;
117
+ const angles = profile.canonical_angles;
118
+ if (!angles || angles.length !== profile.canonical.length) return null;
119
+ const deg = angles[index];
120
+ return typeof deg === "number" ? deg : null;
121
+ }
122
+
123
+ /** The label for canonical view #i β€” prefers the real azimuth_deg (degrees +
124
+ * nearest semantic name, degrees are truth per the backend's documented
125
+ * convention), falling back to today's honest ordinal ("view N") when the
126
+ * angle for THIS view is absent/null (or the profile itself isn't available,
127
+ * e.g. the bound chip after its profile was archived out from under it). Never
128
+ * guesses, never infers an angle from array index β€” a profile with no angle
129
+ * data renders byte-identical to before this slice. */
130
+ export function viewLabel(profile: IdentityProfile | null, index: number): string {
131
+ const deg = angleFor(profile, index);
132
+ if (deg == null) return `view ${index + 1}`;
133
+ const rounded = Math.round(deg);
134
+ return `${rounded}Β° ${semanticNameFor(deg)}`;
135
+ }
136
+
137
+ export interface GenIdentityBarProps {
138
+ /** The saved profiles β€” from the station's SINGLE useIdentityProfiles() instance. */
139
+ profiles: IdentityProfile[];
140
+ /** List still loading (station-level hook). */
141
+ loading: boolean;
142
+ /** List failed to load (station-level hook), or null. */
143
+ error: string | null;
144
+ /** The generation-wide bound identity, or null when none is bound. */
145
+ bound: BoundIdentity | null;
146
+ /** Bind this profile using canonical view `viewIndex` as the ONE carrying view:
147
+ * the station resolves that single CANONICAL 3D-model view (not the raw
148
+ * reference_images source uploads) β†’ exactly one image part, then binds.
149
+ * Resolves to a human error string, or null on success β€” a profile with no
150
+ * canonical views (or whose view won't resolve) fails loudly here, never
151
+ * silently no-ops and never substitutes a different set. */
152
+ onBind: (profile: IdentityProfile, viewIndex: number) => Promise<string | null>;
153
+ /** Unbind: drops the identity's image part and clears the binding. */
154
+ onClear: () => void;
155
+ /** True on the part that owns the expanded picker panel (only one at a time). */
156
+ disabled?: boolean;
157
+ }
158
+
159
+ export function GenIdentityBar({
160
+ profiles,
161
+ loading,
162
+ error,
163
+ bound,
164
+ onBind,
165
+ onClear,
166
+ disabled,
167
+ }: GenIdentityBarProps) {
168
+ const [open, setOpen] = useState(false);
169
+ const [busy, setBusy] = useState(false);
170
+ // STEP 2 state: the profile whose canonical row is being shown for view-pick.
171
+ // null = step 1 (choose an identity). Kept as the SLUG (not the object) so a
172
+ // profile-list refresh can't strand us on a stale copy.
173
+ const [pickingSlug, setPickingSlug] = useState<string | null>(null);
174
+ // Failure is LOCAL and explicit: a profile whose views won't resolve must say
175
+ // so here, never silently no-op (the brief's honest-failure rule).
176
+ const [msg, setMsg] = useState<string | null>(null);
177
+
178
+ const picking = pickingSlug
179
+ ? profiles.find((p) => p.slug === pickingSlug) ?? null
180
+ : null;
181
+
182
+ // The bound chip needs the SAME profile (for its canonical_angles) to label the
183
+ // carrying view honestly. Looked up by slug from the station's live list rather
184
+ // than carried on BoundIdentity itself, so a profile refresh (e.g. the backend
185
+ // angle backfill landing mid-session) is picked up automatically. Falls back to
186
+ // the honest ordinal via viewLabel's own null-handling when the profile isn't
187
+ // found (e.g. it was archived out from under an existing bind).
188
+ const boundProfile = bound ? profiles.find((p) => p.slug === bound.slug) ?? null : null;
189
+
190
+ function reset() {
191
+ setOpen(false);
192
+ setPickingSlug(null);
193
+ setMsg(null);
194
+ }
195
+
196
+ /** Step 1 β†’ 2. A profile with no canonical set fails loudly here rather than
197
+ * opening an empty row (which would read as "nothing to pick" β€” a silent
198
+ * dead end). */
199
+ function chooseProfile(profile: IdentityProfile) {
200
+ if (disabled || busy) return;
201
+ setMsg(null);
202
+ if (profile.canonical.length === 0) {
203
+ setMsg(
204
+ `β€œ${profile.name}” has no canonical 3D-model views yet β€” render its 3D model in the Identities station first.`,
205
+ );
206
+ return;
207
+ }
208
+ setPickingSlug(profile.slug);
209
+ }
210
+
211
+ /** Step 2 β€” commit the ONE carrying view. */
212
+ async function pickView(profile: IdentityProfile, viewIndex: number) {
213
+ if (disabled || busy) return;
214
+ setBusy(true);
215
+ setMsg(null);
216
+ try {
217
+ const failure = await onBind(profile, viewIndex);
218
+ if (failure) {
219
+ setMsg(failure);
220
+ return;
221
+ }
222
+ reset();
223
+ } finally {
224
+ setBusy(false);
225
+ }
226
+ }
227
+
228
+ return (
229
+ <div className="vi-gen-part-identity">
230
+ <div className="vi-gen-part-identity-row">
231
+ {bound ? (
232
+ <>
233
+ <span
234
+ className="vi-gen-identity-chip"
235
+ title={`Identity profile: ${bound.name} β€” carried by canonical ${viewLabel(
236
+ boundProfile,
237
+ bound.viewIndex,
238
+ )}`}
239
+ >
240
+ <span className="vi-gen-identity-chip-icon" aria-hidden>
241
+ πŸ‘€
242
+ </span>
243
+ {bound.name}
244
+ <span className="vi-gen-identity-chip-view">
245
+ {viewLabel(boundProfile, bound.viewIndex)}
246
+ </span>
247
+ </span>
248
+ {/* Wording is deliberately generation-wide, not part-scoped: one identity
249
+ is shared by every part, and this line renders identically on all of
250
+ them. */}
251
+ <span className="vi-gen-identity-note">
252
+ character for this whole generation β€” shared by every part
253
+ </span>
254
+ <button
255
+ type="button"
256
+ className="vi-btn vi-btn-sm vi-btn-ghost"
257
+ disabled={disabled || busy}
258
+ onClick={() => {
259
+ setMsg(null);
260
+ onClear();
261
+ }}
262
+ title="Unbind this identity and remove its 3D-model view part"
263
+ >
264
+ βœ• identity
265
+ </button>
266
+ </>
267
+ ) : (
268
+ <>
269
+ <button
270
+ type="button"
271
+ className="vi-btn vi-btn-sm vi-btn-ghost"
272
+ disabled={disabled || busy || loading}
273
+ aria-expanded={open}
274
+ onClick={() => {
275
+ setMsg(null);
276
+ setPickingSlug(null);
277
+ setOpen((v) => !v);
278
+ }}
279
+ title="Reference a saved character identity for this generation"
280
+ >
281
+ {open
282
+ ? "hide identities"
283
+ : `πŸ‘€ identity${profiles.length ? ` (${profiles.length})` : ""}`}
284
+ </button>
285
+ {open && (
286
+ <span className="vi-gen-identity-note">
287
+ one character, shared by every part of this generation
288
+ </span>
289
+ )}
290
+ </>
291
+ )}
292
+ </div>
293
+
294
+ {open && !bound && (
295
+ <div className="vi-gen-identity-picker">
296
+ {loading ? (
297
+ <p className="vi-gen-part-meta">Loading identities…</p>
298
+ ) : error ? (
299
+ <p className="vi-error" role="alert">
300
+ {error}
301
+ </p>
302
+ ) : profiles.length === 0 ? (
303
+ <p className="vi-gen-part-meta">
304
+ No saved identities yet β€” create one in the Identities station, then
305
+ reference it here.
306
+ </p>
307
+ ) : picking ? (
308
+ /* STEP 2 β€” the canonical ROW. Renders whatever profile.canonical holds
309
+ (4 today, 8 once the parallel azimuth slice lands, N tomorrow): the
310
+ row wraps + scrolls rather than assuming a count. */
311
+ <>
312
+ <div className="vi-gen-identity-views-head">
313
+ <button
314
+ type="button"
315
+ className="vi-btn vi-btn-sm vi-btn-ghost"
316
+ disabled={busy}
317
+ onClick={() => {
318
+ setMsg(null);
319
+ setPickingSlug(null);
320
+ }}
321
+ title="Back to the identity list"
322
+ >
323
+ β€Ή identities
324
+ </button>
325
+ <span className="vi-gen-identity-note">
326
+ {/* HONEST COPY: says exactly why it's one β€” no implication that the
327
+ other views condition the generation, because they would not. */}
328
+ pick the ONE view that carries β€œ{picking.name}” β€” only this image
329
+ conditions the generation, so choose the angle closest to the shot
330
+ you want
331
+ </span>
332
+ </div>
333
+ <div className="vi-gen-identity-views" role="list">
334
+ {picking.canonical.map((path, i) => (
335
+ <button
336
+ key={path}
337
+ type="button"
338
+ role="listitem"
339
+ className="vi-gen-identity-view"
340
+ disabled={disabled || busy}
341
+ onClick={() => void pickView(picking, i)}
342
+ title={`Carry β€œ${picking.name}” with canonical ${viewLabel(picking, i)}`}
343
+ >
344
+ <img
345
+ className="vi-gen-identity-view-thumb"
346
+ src={mediaBytesUrl(path)}
347
+ alt={`${picking.name} canonical ${viewLabel(picking, i)}`}
348
+ loading="lazy"
349
+ />
350
+ {/* Real azimuth (degrees + nearest semantic name) when this
351
+ view has one; the honest ordinal ("view N") when it
352
+ doesn't β€” see viewLabel above. */}
353
+ <span className="vi-gen-identity-view-label">{viewLabel(picking, i)}</span>
354
+ </button>
355
+ ))}
356
+ </div>
357
+ </>
358
+ ) : (
359
+ /* STEP 1 β€” choose an identity. */
360
+ profiles.map((p) => {
361
+ // A bind attaches ONE CANONICAL turntable view (a render of the 3D
362
+ // model), not the raw reference_images β€” so the picker's
363
+ // preview/count/title must describe canonical, or a user would be
364
+ // told one thing and get another. A profile with no canonical set yet
365
+ // (no 3D model rendered) is still listed β€” clicking it fails loudly β€”
366
+ // but the affordance says so up front instead of implying it's ready.
367
+ const hasCanonical = p.canonical.length > 0;
368
+ const title = hasCanonical
369
+ ? `Choose a carrying view for β€œ${p.name}” (${p.canonical.length} 3D-model view${
370
+ p.canonical.length === 1 ? "" : "s"
371
+ } to pick from)`
372
+ : `β€œ${p.name}” has no 3D-model views yet β€” render its 3D model first`;
373
+ return (
374
+ <button
375
+ key={p.slug}
376
+ type="button"
377
+ className="vi-gen-identity-option"
378
+ disabled={disabled || busy}
379
+ onClick={() => chooseProfile(p)}
380
+ title={title}
381
+ >
382
+ {hasCanonical ? (
383
+ <img
384
+ className="vi-gen-identity-option-thumb"
385
+ src={mediaBytesUrl(p.canonical[0])}
386
+ alt=""
387
+ loading="lazy"
388
+ />
389
+ ) : (
390
+ <span
391
+ className="vi-gen-identity-option-thumb vi-gen-identity-option-thumb-empty"
392
+ aria-hidden
393
+ >
394
+ ⚠
395
+ </span>
396
+ )}
397
+ <span className="vi-gen-identity-option-name">{p.name}</span>
398
+ </button>
399
+ );
400
+ })
401
+ )}
402
+ </div>
403
+ )}
404
+
405
+ {msg && (
406
+ <p className="vi-error" role="alert">
407
+ {msg}
408
+ </p>
409
+ )}
410
+ </div>
411
+ );
412
+ }