@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,2455 @@
1
+ // IDENTITIES — the dedicated station for PROCURING and EDITING identity profiles
2
+ // (studio stage (a)'s durable library item), separate from the compact "👤 From
3
+ // profile" picker the Studio Generate/Movie surfaces embed. Those surfaces only
4
+ // ATTACH a saved identity to a render; this station is where one is actually
5
+ // built and maintained:
6
+ // • a CREATE panel (name + notes + reference images, upload or library-pick)
7
+ // • a GRID of existing profiles (thumbnail strip, name, created date, notes
8
+ // preview, ref count), each with inline EDIT (rename display-only + notes +
9
+ // add/remove references) and ARCHIVE.
10
+ //
11
+ // Backed entirely by useIdentityProfiles (list/create/update/remove) — this file
12
+ // adds no new wire contract, just the surface. The reference-image upload path
13
+ // is the EXACT upload→ingest→guard-kind flow StudioGenerateTab's onPickImageFile
14
+ // / StudioMovieComposer's run (POST /uploads → POST /video/ingest → MediaRef);
15
+ // the library pick reuses studioShared's LibraryImageGrid + useMediaLibrary,
16
+ // unmodified — both imported, neither file touched.
17
+ //
18
+ // USE-IN-STUDIO (operator ask: "a tab for identities, dedicated to procuring
19
+ // them, editing them, etc"): a per-card "Use in Studio" jump is SKIPPED here — a
20
+ // clean navigation/state handoff into the Studio station's bound-profile state
21
+ // would mean reaching into the Studio plane/bridge files a concurrent change was
22
+ // touching at build time. The generate surfaces already offer "👤 From profile"
23
+ // (IdentityProfileControls) as the attach point, so nothing is unreachable in
24
+ // the meantime — just not a one-click deep link from here yet. Follow-up, not a
25
+ // gap in capability.
26
+ import { useCallback, useState, useEffect, useRef } from "react";
27
+ import * as THREE from "three";
28
+ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
29
+ import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
30
+ import { request, okValue, errorOf, describeAppError } from "../../transport/client";
31
+ import { hugpyConfig, mediaBytesUrl, identityGenerateUrl, identityMeshStatusUrl, videoJobUrl } from "../../config";
32
+ import { addToLibrary, useMediaLibrary } from "../../video/mediaLibrary";
33
+ import type { LibraryItem } from "../../video/mediaLibrary";
34
+ import { mediaRefSchema, uploadResultSchema } from "../../video/contract";
35
+ import type { MediaRef, IdentityReconstruction, IdentityAngleView } from "../../video/contract";
36
+ import { getSessionId } from "../../session";
37
+ import { LibraryImageGrid } from "./studioShared";
38
+ import { useIdentityProfiles } from "./useIdentityProfiles";
39
+ import type {
40
+ IdentityProfile,
41
+ IdentityProfilesState,
42
+ GenSettings,
43
+ IdentityVersion,
44
+ Char360Params,
45
+ } from "./useIdentityProfiles";
46
+ import { useModelsByTask } from "../../video/useModels";
47
+ import { useReconstruction, DEFAULT_VIEWS, VIEW_LABELS } from "./useReconstruction";
48
+ import type { ReconstructionApi } from "./useReconstruction";
49
+ import { TurntableViewer } from "./TurntableViewer";
50
+ import { viewLabel } from "../GenIdentityBar";
51
+ import type { StationSpec } from "../types";
52
+
53
+ // The VL capability the per-identity vision-model picker filters the registry by — the
54
+ // SAME task the Movie director's judge-VLM select uses (see GenerateStation). The
55
+ // 3D-imaging front-select step is an image->text ask, so only image-text-to-text
56
+ // models are offered.
57
+ const VISION_TASK = "image-text-to-text";
58
+
59
+ // Increased from 4 to 12. Canonical anchors stay at 4 (handled via promote limits)
60
+ const MAX_SOURCE_IMAGES = 12;
61
+
62
+ type UploadPhase = "idle" | "uploading" | "ingesting";
63
+
64
+ /** Upload → ingest → guard kind=image — the same two-hop path every other studio
65
+ * surface's image upload runs (StudioGenerateTab.onPickImageFile / StudioMovie-
66
+ * Composer's mirror of it), lifted to a standalone helper since this station
67
+ * has no single host reference-slot list to route into (create panel + N cards
68
+ * each keep their own draft ref list). Joins the session library on success,
69
+ * exactly like the host surfaces do, so an image picked here is pickable from
70
+ * the library everywhere else too. */
71
+ async function uploadAndIngestImage(file: File): Promise<{ ref?: MediaRef; error?: string }> {
72
+ const fd = new FormData();
73
+ fd.append("file", file);
74
+ fd.append("sid", getSessionId());
75
+ const up = await request<unknown>(hugpyConfig.uploadUrl, {
76
+ method: "POST",
77
+ body: fd,
78
+ meta: { specKey: "studio", operation: "identity.station.upload" },
79
+ });
80
+ if (!up.ok) return { error: describeAppError(errorOf(up)) };
81
+ const upParsed = uploadResultSchema.safeParse(okValue(up));
82
+ if (!upParsed.success) return { error: "Malformed upload response." };
83
+ const ing = await request<unknown>(hugpyConfig.videoIngestUrl, {
84
+ method: "POST",
85
+ body: JSON.stringify({ path: upParsed.data.path }),
86
+ headers: { "Content-Type": "application/json" },
87
+ meta: { specKey: "studio", operation: "identity.station.ingest" },
88
+ });
89
+ if (!ing.ok) return { error: describeAppError(errorOf(ing)) };
90
+ const media = mediaRefSchema.safeParse(okValue(ing));
91
+ if (!media.success) return { error: "Malformed ingest response." };
92
+ if (media.data.kind !== "image") {
93
+ return { error: `Ingested asset is "${media.data.kind}", not an image — pick a still.` };
94
+ }
95
+ addToLibrary(media.data, "upload", media.data.mime);
96
+ return { ref: media.data };
97
+ }
98
+
99
+ /** char360 S4 — the same upload→ingest chain as `uploadAndIngestImage`, guarding
100
+ * the INVERSE kind: the "Extract from video" control's source MUST be a video,
101
+ * not a still. Kept as a sibling helper (not a shared parameterized one) so
102
+ * neither existing call site's behavior/wording changes — extend, don't touch. */
103
+ async function uploadAndIngestVideo(file: File): Promise<{ ref?: MediaRef; error?: string }> {
104
+ const fd = new FormData();
105
+ fd.append("file", file);
106
+ fd.append("sid", getSessionId());
107
+ const up = await request<unknown>(hugpyConfig.uploadUrl, {
108
+ method: "POST",
109
+ body: fd,
110
+ meta: { specKey: "studio", operation: "identity.video_extract.upload" },
111
+ });
112
+ if (!up.ok) return { error: describeAppError(errorOf(up)) };
113
+ const upParsed = uploadResultSchema.safeParse(okValue(up));
114
+ if (!upParsed.success) return { error: "Malformed upload response." };
115
+ const ing = await request<unknown>(hugpyConfig.videoIngestUrl, {
116
+ method: "POST",
117
+ body: JSON.stringify({ path: upParsed.data.path }),
118
+ headers: { "Content-Type": "application/json" },
119
+ meta: { specKey: "studio", operation: "identity.video_extract.ingest" },
120
+ });
121
+ if (!ing.ok) return { error: describeAppError(errorOf(ing)) };
122
+ const media = mediaRefSchema.safeParse(okValue(ing));
123
+ if (!media.success) return { error: "Malformed ingest response." };
124
+ if (media.data.kind !== "video") {
125
+ return { error: `Ingested asset is "${media.data.kind}", not a video — pick a video file.` };
126
+ }
127
+ addToLibrary(media.data, "upload", media.data.mime);
128
+ return { ref: media.data };
129
+ }
130
+
131
+ function fmtCreated(t: number | null | undefined): { short: string; full: string } {
132
+ if (t == null) return { short: "", full: "" };
133
+ try {
134
+ const d = new Date(t * 1000);
135
+ return { short: d.toLocaleDateString(), full: d.toLocaleString() };
136
+ } catch {
137
+ return { short: "", full: "" };
138
+ }
139
+ }
140
+
141
+ // ── GLB Mesh Viewer (Phase 4) ────────────────────────────────────────────────
142
+ export function MeshViewer({ url, height = 420 }: { url: string; height?: number }) {
143
+ const hostRef = useRef<HTMLDivElement | null>(null);
144
+
145
+ useEffect(() => {
146
+ const host = hostRef.current;
147
+ if (!host) return;
148
+
149
+ const scene = new THREE.Scene();
150
+ scene.background = new THREE.Color(0x111111);
151
+
152
+ const camera = new THREE.PerspectiveCamera(35, host.clientWidth / height, 0.01, 1000);
153
+ camera.position.set(0, 1.2, 3);
154
+
155
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
156
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
157
+ renderer.setSize(host.clientWidth, height);
158
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
159
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
160
+
161
+ host.appendChild(renderer.domElement);
162
+
163
+ const controls = new OrbitControls(camera, renderer.domElement);
164
+ controls.enableDamping = true;
165
+ controls.target.set(0, 1, 0);
166
+
167
+ scene.add(new THREE.HemisphereLight(0xffffff, 0x333333, 2));
168
+ const keyLight = new THREE.DirectionalLight(0xffffff, 3);
169
+ keyLight.position.set(3, 5, 4);
170
+ scene.add(keyLight);
171
+
172
+ const loader = new GLTFLoader();
173
+ let loadedRoot: THREE.Object3D | null = null;
174
+ let animationFrame = 0;
175
+
176
+ loader.load(
177
+ url,
178
+ (gltf) => {
179
+ loadedRoot = gltf.scene;
180
+ scene.add(gltf.scene);
181
+
182
+ const box = new THREE.Box3().setFromObject(gltf.scene);
183
+ const size = box.getSize(new THREE.Vector3());
184
+ const center = box.getCenter(new THREE.Vector3());
185
+
186
+ gltf.scene.position.sub(center);
187
+
188
+ const largest = Math.max(size.x, size.y, size.z, 0.001);
189
+ camera.position.set(0, size.y * 0.15, largest * 2.2);
190
+ camera.near = largest / 100;
191
+ camera.far = largest * 100;
192
+ camera.updateProjectionMatrix();
193
+
194
+ controls.target.set(0, 0, 0);
195
+ controls.update();
196
+ },
197
+ undefined,
198
+ (error) => console.error("Could not load identity GLB", error)
199
+ );
200
+
201
+ const resize = () => {
202
+ const width = host.clientWidth;
203
+ camera.aspect = width / height;
204
+ camera.updateProjectionMatrix();
205
+ renderer.setSize(width, height);
206
+ };
207
+
208
+ const observer = new ResizeObserver(resize);
209
+ observer.observe(host);
210
+
211
+ const render = () => {
212
+ controls.update();
213
+ renderer.render(scene, camera);
214
+ animationFrame = requestAnimationFrame(render);
215
+ };
216
+ render();
217
+
218
+ return () => {
219
+ cancelAnimationFrame(animationFrame);
220
+ observer.disconnect();
221
+ controls.dispose();
222
+
223
+ if (loadedRoot) {
224
+ loadedRoot.traverse((object) => {
225
+ if (!(object instanceof THREE.Mesh)) return;
226
+ object.geometry.dispose();
227
+ const materials = Array.isArray(object.material) ? object.material : [object.material];
228
+ for (const material of materials) {
229
+ for (const value of Object.values(material)) {
230
+ if (value instanceof THREE.Texture) value.dispose();
231
+ }
232
+ material.dispose();
233
+ }
234
+ });
235
+ }
236
+ renderer.dispose();
237
+ renderer.domElement.remove();
238
+ };
239
+ }, [url, height]);
240
+
241
+ return (
242
+ <div
243
+ ref={hostRef}
244
+ style={{
245
+ width: "100%",
246
+ height,
247
+ overflow: "hidden",
248
+ borderRadius: "0.5rem",
249
+ border: "1px solid var(--vi-border)",
250
+ }}
251
+ />
252
+ );
253
+ }
254
+
255
+ // ── Angle Ring Editor (Phase 3) ─────────────────────────────────────────────
256
+ function AngleRingTile({
257
+ view,
258
+ reconId,
259
+ api,
260
+ promptOverride,
261
+ }: {
262
+ view: IdentityAngleView;
263
+ reconId: string;
264
+ api: ReconstructionApi;
265
+ promptOverride: string;
266
+ }) {
267
+ const isGenerating = view.status === "pending" || view.status === "generating";
268
+ const hasImage = view.image_uri != null; // <--- snake_case
269
+
270
+ return (
271
+ <div
272
+ className={`vi-scene-frame-cell vi-angle-tile vi-angle-tile-${view.status}`}
273
+ style={{ display: "flex", flexDirection: "column", gap: "0.2rem", alignItems: "center", width: "5rem" }}
274
+ >
275
+ <div
276
+ style={{
277
+ position: "relative",
278
+ width: "5rem",
279
+ height: "5rem",
280
+ borderRadius: "0.35rem",
281
+ overflow: "hidden",
282
+ background: "#000",
283
+ border: view.status === "approved" ? "2px solid var(--vi-accent, #6aa8ff)" : "1px solid var(--vi-border)",
284
+ }}
285
+ >
286
+ {hasImage ? (
287
+ <img
288
+ className="vi-scene-frame-img"
289
+ src={mediaBytesUrl(view.image_uri!)} // <--- snake_case
290
+ alt={`${view.azimuth_deg}° view`} // <--- snake_case
291
+ loading="lazy"
292
+ style={{ width: "100%", height: "100%", objectFit: "cover" }}
293
+ />
294
+ ) : (
295
+ <span
296
+ className="vi-comfy-hint"
297
+ style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.6rem", opacity: 0.6 }}
298
+ >
299
+ {isGenerating ? "Generating…" : "No Image"}
300
+ </span>
301
+ )}
302
+ <span
303
+ className="vi-frame-tag"
304
+ style={{ position: "absolute", bottom: 2, left: 3, fontSize: "0.75rem", textShadow: "0 0 3px #000" }}
305
+ >
306
+ {view.azimuth_deg}°
307
+ </span>
308
+ </div>
309
+
310
+ <span className="vi-comfy-hint" style={{ fontSize: "0.6rem", opacity: 0.85, lineHeight: 1.1 }}>
311
+ {view.status || "pending"} {view.version ? `(v${view.version})` : ""}
312
+ </span>
313
+
314
+ <div style={{ display: "flex", gap: "0.2rem", flexWrap: "wrap", justifyContent: "center" }}>
315
+ <button
316
+ type="button"
317
+ className="vi-btn vi-btn-sm vi-btn-ghost"
318
+ disabled={!hasImage || view.status === "approved" || isGenerating}
319
+ onClick={() => void api.setViewStatus(reconId, view.view_id, "approved")} // <--- snake_case
320
+ title="Approve this angle"
321
+ style={{ fontSize: "0.65rem", padding: "0 0.35rem" }}
322
+ >
323
+ ✓
324
+ </button>
325
+ <button
326
+ type="button"
327
+ className="vi-btn vi-btn-sm vi-btn-ghost"
328
+ disabled={!hasImage || view.status === "rejected" || isGenerating}
329
+ onClick={() => void api.setViewStatus(reconId, view.view_id, "rejected")} // <--- snake_case
330
+ title="Reject this angle"
331
+ style={{ fontSize: "0.65rem", padding: "0 0.35rem" }}
332
+ >
333
+ ✕
334
+ </button>
335
+ <button
336
+ type="button"
337
+ className="vi-btn vi-btn-sm vi-btn-ghost"
338
+ disabled={isGenerating}
339
+ onClick={() => void api.regenerateView(reconId, view.view_id, promptOverride, undefined, true)} // <--- snake_case
340
+ title="Regenerate using nearest approved neighbors"
341
+ style={{ fontSize: "0.65rem", padding: "0 0.35rem", flexBasis: "100%" }}
342
+ >
343
+ ↻ Regen
344
+ </button>
345
+ </div>
346
+ </div>
347
+ );
348
+ }
349
+
350
+ function AngleRingEditor({ reconstruction, api }: { reconstruction: IdentityReconstruction; api: ReconstructionApi }) {
351
+ const [regeneratePrompt, setRegeneratePrompt] = useState("");
352
+
353
+ const views = reconstruction.views ?? [];
354
+ const approvedCount = views.filter((v: any) => v.status === "approved").length;
355
+ const allViewsApproved = views.length > 0 && approvedCount === views.length;
356
+ const meshRunning = reconstruction.mesh?.status === "queued" || reconstruction.mesh?.status === "running";
357
+
358
+ const getMeshAnchorIds = () => {
359
+ const anchorDegrees = [0, 45, 90, 135, 180, 225, 270, 315];
360
+ return views.filter((v: any) => anchorDegrees.includes(v.azimuth_deg)).map((v: any) => v.view_id); // <--- snake_case
361
+ };
362
+
363
+ return (
364
+ <div style={{ marginTop: "0.4rem" }}>
365
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.4rem" }}>
366
+ <p className="vi-comfy-label" style={{ margin: 0 }}>
367
+ Angle Ring Approval ({approvedCount}/{views.length})
368
+ </p>
369
+ <button
370
+ className="vi-btn vi-btn-sm vi-btn-accent"
371
+ disabled={!allViewsApproved || meshRunning || api.running}
372
+ onClick={() => api.buildMesh(reconstruction.recon_id, getMeshAnchorIds())} // <--- snake_case
373
+ >
374
+ {meshRunning ? "Building 3D Mesh…" : "Build Approved 3D Model"}
375
+ </button>
376
+ </div>
377
+
378
+ {reconstruction.mesh?.error && (
379
+ <p className="vi-error" style={{ marginBottom: "0.4rem" }}>Mesh Error: {reconstruction.mesh.error}</p>
380
+ )}
381
+
382
+ <label className="vi-comfy-field" style={{ marginBottom: "0.6rem" }}>
383
+ <span className="vi-comfy-label">Global Regeneration Prompt</span>
384
+ <input
385
+ type="text"
386
+ className="vi-knob-input"
387
+ placeholder="e.g. Preserve exact identity, clothing, and proportions..."
388
+ value={regeneratePrompt}
389
+ onChange={(e) => setRegeneratePrompt(e.target.value)}
390
+ />
391
+ <span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
392
+ Used when regenerating specific rejected angles below. Conditioned on nearest approved neighbors.
393
+ </span>
394
+ </label>
395
+
396
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.45rem", maxHeight: "28rem", overflowY: "auto", paddingRight: "0.5rem" }}>
397
+ {views.map((view: any) => (
398
+ <AngleRingTile
399
+ key={view.view_id} // <--- snake_case
400
+ view={view}
401
+ reconId={reconstruction.recon_id} // <--- snake_case
402
+ api={api}
403
+ promptOverride={regeneratePrompt}
404
+ />
405
+ ))}
406
+ </div>
407
+ </div>
408
+ );
409
+ }
410
+
411
+ // ── a small reusable "N/12 thumbnails + add (upload | library)" block ───────
412
+ function ReferenceEditor({
413
+ refs,
414
+ onAdd,
415
+ onRemove,
416
+ libraryImages,
417
+ busy,
418
+ }: {
419
+ refs: string[];
420
+ onAdd: (ref: MediaRef) => void;
421
+ onRemove: (idx: number) => void;
422
+ libraryImages: LibraryItem[];
423
+ busy: boolean;
424
+ }) {
425
+ const [showLib, setShowLib] = useState(false);
426
+ const canAdd = refs.length < MAX_SOURCE_IMAGES;
427
+
428
+ return (
429
+ <div style={{ marginTop: "0.4rem" }}>
430
+ {refs.length > 0 && (
431
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.4rem", marginBottom: "0.4rem" }}>
432
+ {refs.map((uri, idx) => (
433
+ <div key={`${uri}_${idx}`} style={{ position: "relative" }}>
434
+ <img
435
+ src={mediaBytesUrl(uri)}
436
+ alt={`reference ${idx + 1}`}
437
+ style={{
438
+ width: "3.6rem",
439
+ height: "3.6rem",
440
+ objectFit: "cover",
441
+ borderRadius: "0.35rem",
442
+ background: "#000",
443
+ border: "1px solid var(--vi-border)",
444
+ }}
445
+ />
446
+ <button
447
+ type="button"
448
+ className="vi-btn vi-btn-sm vi-btn-ghost"
449
+ disabled={busy}
450
+ onClick={() => onRemove(idx)}
451
+ title="Remove this reference"
452
+ style={{
453
+ position: "absolute",
454
+ top: -6,
455
+ right: -6,
456
+ padding: "0 0.3rem",
457
+ fontSize: "0.7rem",
458
+ lineHeight: 1.4,
459
+ background: "var(--vi-border)",
460
+ borderRadius: "0.3rem",
461
+ }}
462
+ >
463
+ ✕
464
+ </button>
465
+ </div>
466
+ ))}
467
+ </div>
468
+ )}
469
+ {canAdd ? (
470
+ <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
471
+ <label className="vi-btn vi-btn-sm vi-btn-accent vi-file-label">
472
+ {busy ? "Working…" : refs.length ? "Add references" : "Upload references"}
473
+ <input
474
+ type="file"
475
+ accept="image/*"
476
+ multiple // 1. Allow selecting multiple files at once
477
+ className="vi-file-input"
478
+ disabled={busy}
479
+ onChange={(e) => {
480
+ const files = e.target.files;
481
+ if (files && files.length > 0) {
482
+ void (async () => {
483
+ // Convert FileList to Array and enforce space limits
484
+ const fileArray = Array.from(files);
485
+ const remainingSlots = MAX_SOURCE_IMAGES - refs.length;
486
+
487
+ if (fileArray.length > remainingSlots) {
488
+ window.alert(`You can only add ${remainingSlots} more image(s). Only the first ${remainingSlots} will be uploaded.`);
489
+ }
490
+
491
+ const filesToUpload = fileArray.slice(0, remainingSlots);
492
+
493
+ // 2. Upload and ingest them concurrently
494
+ await Promise.all(
495
+ filesToUpload.map(async (f) => {
496
+ const { ref, error } = await uploadAndIngestImage(f);
497
+ if (ref) onAdd(ref);
498
+ if (error) console.error(error);
499
+ })
500
+ );
501
+ })();
502
+ }
503
+ e.target.value = "";
504
+ }}
505
+ />
506
+ </label>
507
+ <button
508
+ type="button"
509
+ className="vi-btn vi-btn-sm vi-btn-ghost"
510
+ disabled={busy}
511
+ onClick={() => setShowLib((v) => !v)}
512
+ aria-expanded={showLib}
513
+ >
514
+ {showLib ? "Hide library" : `Pick from library (${libraryImages.length})`}
515
+ </button>
516
+ <span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
517
+ {refs.length}/{MAX_SOURCE_IMAGES}
518
+ </span>
519
+ </div>
520
+ ) : (
521
+ <p className="vi-comfy-hint" role="note">
522
+ Maximum {MAX_SOURCE_IMAGES} source reference images.
523
+ </p>
524
+ )}
525
+ {showLib && canAdd && (
526
+ <LibraryImageGrid
527
+ images={libraryImages}
528
+ onPick={(ref) => {
529
+ onAdd(ref);
530
+ }}
531
+ />
532
+ )}
533
+ </div>
534
+ );
535
+ }
536
+
537
+ // ── char360 S4 — "Extract from video" panel ─────────────────────────────────
538
+ // Mints (target:"create") or adds views to (target:"<slug>") an identity
539
+ // profile from a source video's char360 view-set extraction. A header-area
540
+ // peer of CreatePanel, not a per-card control — a "create" extract mints NEW
541
+ // profiles, so it doesn't belong to any one existing card.
542
+ //
543
+ // t25: `presetVideo` lets the Frames & Models station reuse this WHOLESALE over
544
+ // its ALREADY-LOADED source video — when provided, the built-in uploader is
545
+ // hidden and the extraction runs against that video. Omitted (the identities
546
+ // door) keeps the self-contained pick-a-video behavior exactly as before.
547
+ export function ExtractFromVideoPanel({
548
+ profiles,
549
+ extractFromVideo,
550
+ reload,
551
+ presetVideo,
552
+ }: {
553
+ profiles: IdentityProfile[];
554
+ extractFromVideo: IdentityProfilesState["extractFromVideo"];
555
+ reload: () => void;
556
+ presetVideo?: MediaRef | null;
557
+ }) {
558
+ const [pickedVideo, setPickedVideo] = useState<MediaRef | null>(null);
559
+ // When a preset video is supplied (Frames station), it IS the source — the
560
+ // panel never runs its own uploader. Otherwise the locally-picked video is used.
561
+ const usingPreset = presetVideo != null;
562
+ const video = usingPreset ? presetVideo : pickedVideo;
563
+ const setVideo = setPickedVideo;
564
+ const [uploadPhase, setUploadPhase] = useState<UploadPhase>("idle");
565
+ const [target, setTarget] = useState<string>("create");
566
+ const [advancedOpen, setAdvancedOpen] = useState(false);
567
+ const [stride, setStride] = useState("");
568
+ const [yoloModel, setYoloModel] = useState("");
569
+ const [minHFrac, setMinHFrac] = useState("");
570
+ const [clusterDist, setClusterDist] = useState("");
571
+ const [minFaces, setMinFaces] = useState("");
572
+ const [busy, setBusy] = useState(false);
573
+ const [status, setStatus] = useState<string | null>(null);
574
+ const [pickError, setPickError] = useState<string | null>(null);
575
+
576
+ const active = useRef(false);
577
+ const timer = useRef<number | null>(null);
578
+
579
+ useEffect(() => {
580
+ return () => {
581
+ active.current = false;
582
+ if (timer.current != null) window.clearTimeout(timer.current);
583
+ };
584
+ }, []);
585
+
586
+ async function onPickFile(file: File) {
587
+ setPickError(null);
588
+ setUploadPhase("uploading");
589
+ const { ref, error } = await uploadAndIngestVideo(file);
590
+ setUploadPhase("ingesting");
591
+ if (error) {
592
+ setPickError(error);
593
+ setVideo(null);
594
+ } else if (ref) {
595
+ setVideo(ref);
596
+ }
597
+ setUploadPhase("idle");
598
+ }
599
+
600
+ async function submit() {
601
+ if (!video || busy) return;
602
+ setBusy(true);
603
+ setStatus("Extracting… (queued)");
604
+ active.current = true;
605
+
606
+ const params: Char360Params = {};
607
+ if (stride.trim()) params.stride = Number(stride);
608
+ if (yoloModel.trim()) params.yolo_model = yoloModel.trim();
609
+ if (minHFrac.trim()) params.min_h_frac = Number(minHFrac);
610
+ if (clusterDist.trim()) params.cluster_dist = Number(clusterDist);
611
+ if (minFaces.trim()) params.min_faces = Number(minFaces);
612
+
613
+ const enq = await extractFromVideo(video, target, Object.keys(params).length ? params : undefined);
614
+ if (!active.current) return;
615
+ if (!enq.ok || !enq.jobId) {
616
+ active.current = false;
617
+ setBusy(false);
618
+ setStatus(enq.message || "Could not start the video extraction.");
619
+ return;
620
+ }
621
+
622
+ const jobId = enq.jobId;
623
+ const deadline = Date.now() + 20 * 60 * 1000; // ~20 min bound, mirrors the /generate poll
624
+ const pollOnce = async () => {
625
+ if (!active.current) return;
626
+ if (Date.now() > deadline) {
627
+ active.current = false;
628
+ setBusy(false);
629
+ setStatus("Timed out waiting for the video extraction.");
630
+ return;
631
+ }
632
+ const res = await request<unknown>(videoJobUrl(jobId), {
633
+ meta: { specKey: "studio", operation: "identity.video_extract.status" },
634
+ });
635
+ if (!active.current) return;
636
+ if (res.ok) {
637
+ const job = okValue(res) as {
638
+ progress?: number | null;
639
+ result?: { ok?: boolean; error?: { code?: string; message?: string; retryable?: boolean } } | null;
640
+ stage?: string | null;
641
+ message?: string | null;
642
+ };
643
+ const result = job.result;
644
+ if (result && typeof result.ok === "boolean") {
645
+ active.current = false;
646
+ setBusy(false);
647
+ if (result.ok) {
648
+ setStatus("done");
649
+ setVideo(null);
650
+ reload();
651
+ } else {
652
+ setStatus((result.error && result.error.message) || "Extraction failed.");
653
+ }
654
+ return;
655
+ }
656
+ const pct = typeof job.progress === "number" ? ` (${Math.round(job.progress * 100)}%)` : "";
657
+ const stage = job.stage || job.message;
658
+ setStatus(`Extracting…${pct}${stage ? ` — ${stage}` : ""}`);
659
+ }
660
+ // queued/running/transient-poll-failure → keep polling.
661
+ timer.current = window.setTimeout(() => void pollOnce(), 5000);
662
+ };
663
+ timer.current = window.setTimeout(() => void pollOnce(), 5000);
664
+ }
665
+
666
+ const canSubmit = video != null && !busy && uploadPhase === "idle";
667
+
668
+ return (
669
+ <section className="vi-comfy-bar" aria-label="Extract character views from a video" style={{ marginTop: "0.8rem" }}>
670
+ <div style={{ flexBasis: "100%" }}>
671
+ <span className="vi-comfy-label">Extract character views (char360)</span>
672
+ <p className="vi-comfy-hint" style={{ margin: "0.15rem 0 0.4rem" }}>
673
+ {usingPreset
674
+ ? "Run the char360 view-set extraction over the loaded video to mint a new identity profile (or add views to an existing one)."
675
+ : "Run the char360 view-set extraction over a video to mint a new identity profile (or add views to an existing one) — no curl required."}
676
+ </p>
677
+ </div>
678
+
679
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.6rem", alignItems: "center" }}>
680
+ {!usingPreset && (
681
+ <label className="vi-btn vi-btn-sm vi-btn-accent vi-file-label">
682
+ {uploadPhase !== "idle" ? "Uploading…" : video ? "Change video" : "Pick a video"}
683
+ <input
684
+ type="file"
685
+ accept="video/*"
686
+ className="vi-file-input"
687
+ disabled={busy || uploadPhase !== "idle"}
688
+ onChange={(e) => {
689
+ const file = e.target.files && e.target.files[0];
690
+ if (file) void onPickFile(file);
691
+ e.target.value = "";
692
+ }}
693
+ />
694
+ </label>
695
+ )}
696
+ {video && (
697
+ <span className="vi-comfy-hint" style={{ opacity: 0.85 }}>
698
+ {usingPreset ? "loaded video · " : ""}
699
+ {video.uri.split("/").pop() || video.asset_id}
700
+ {video.duration_s ? ` · ${video.duration_s.toFixed(1)}s` : ""}
701
+ </span>
702
+ )}
703
+ {usingPreset && !video && (
704
+ <span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
705
+ Load a video above to extract a character view-set from it.
706
+ </span>
707
+ )}
708
+ </div>
709
+ {pickError && (
710
+ <p className="vi-error" role="alert" style={{ flexBasis: "100%", margin: 0 }}>
711
+ {pickError}
712
+ </p>
713
+ )}
714
+
715
+ <label className="vi-comfy-field">
716
+ <span className="vi-comfy-label">Target</span>
717
+ <select
718
+ className="vi-knob-input"
719
+ value={target}
720
+ disabled={busy}
721
+ onChange={(e) => setTarget(e.target.value)}
722
+ >
723
+ <option value="create">Create new profile(s)</option>
724
+ {profiles.map((p) => (
725
+ <option key={p.slug} value={p.slug}>
726
+ Add to: {p.name}
727
+ </option>
728
+ ))}
729
+ </select>
730
+ </label>
731
+
732
+ <div className="vi-comfy-bar-actions vi-run-bar" style={{ flexWrap: "wrap" }}>
733
+ <button
734
+ type="button"
735
+ className="vi-btn vi-btn-sm vi-btn-ghost"
736
+ onClick={() => setAdvancedOpen((v) => !v)}
737
+ aria-expanded={advancedOpen}
738
+ title="char360 extraction knobs — blank means the service default"
739
+ >
740
+ {advancedOpen ? "Advanced ▾" : "Advanced ▸"}
741
+ </button>
742
+ <button type="button" className="vi-btn vi-btn-accent" disabled={!canSubmit} onClick={() => void submit()}>
743
+ {busy ? "Extracting…" : "Extract from video"}
744
+ </button>
745
+ {status && (
746
+ <p className="vi-comfy-hint" role="status" style={{ flexBasis: "100%", margin: 0 }}>
747
+ {status}
748
+ </p>
749
+ )}
750
+ </div>
751
+
752
+ {advancedOpen && (
753
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.6rem", flexBasis: "100%" }}>
754
+ <label className="vi-comfy-field" style={{ flexBasis: "7rem" }}>
755
+ <span className="vi-comfy-label">Stride</span>
756
+ <input
757
+ type="number"
758
+ className="vi-knob-input"
759
+ min={1}
760
+ placeholder="8"
761
+ value={stride}
762
+ disabled={busy}
763
+ onChange={(e) => setStride(e.target.value)}
764
+ />
765
+ </label>
766
+ <label className="vi-comfy-field" style={{ flexBasis: "10rem" }}>
767
+ <span className="vi-comfy-label">YOLO model</span>
768
+ <input
769
+ type="text"
770
+ className="vi-knob-input"
771
+ placeholder="service default"
772
+ value={yoloModel}
773
+ disabled={busy}
774
+ onChange={(e) => setYoloModel(e.target.value)}
775
+ />
776
+ </label>
777
+ <label className="vi-comfy-field" style={{ flexBasis: "8rem" }}>
778
+ <span className="vi-comfy-label">Min height frac</span>
779
+ <input
780
+ type="number"
781
+ className="vi-knob-input"
782
+ step="0.01"
783
+ min={0}
784
+ max={1}
785
+ placeholder="default"
786
+ value={minHFrac}
787
+ disabled={busy}
788
+ onChange={(e) => setMinHFrac(e.target.value)}
789
+ />
790
+ </label>
791
+ <label className="vi-comfy-field" style={{ flexBasis: "8rem" }}>
792
+ <span className="vi-comfy-label">Cluster dist</span>
793
+ <input
794
+ type="number"
795
+ className="vi-knob-input"
796
+ step="0.01"
797
+ min={0}
798
+ placeholder="default"
799
+ value={clusterDist}
800
+ disabled={busy}
801
+ onChange={(e) => setClusterDist(e.target.value)}
802
+ />
803
+ </label>
804
+ <label className="vi-comfy-field" style={{ flexBasis: "7rem" }}>
805
+ <span className="vi-comfy-label">Min faces</span>
806
+ <input
807
+ type="number"
808
+ className="vi-knob-input"
809
+ min={0}
810
+ placeholder="default"
811
+ value={minFaces}
812
+ disabled={busy}
813
+ onChange={(e) => setMinFaces(e.target.value)}
814
+ />
815
+ </label>
816
+ </div>
817
+ )}
818
+ </section>
819
+ );
820
+ }
821
+
822
+ // ── CREATE panel ──────────────────────────────────────────────────────────────
823
+ function CreatePanel({
824
+ create,
825
+ libraryImages,
826
+ }: {
827
+ create: IdentityProfilesState["create"];
828
+ libraryImages: LibraryItem[];
829
+ }) {
830
+ const [name, setName] = useState("");
831
+ const [notes, setNotes] = useState("");
832
+ const [refs, setRefs] = useState<MediaRef[]>([]);
833
+ const [busy, setBusy] = useState(false);
834
+ const [msg, setMsg] = useState<string | null>(null);
835
+
836
+ const addRef = useCallback((ref: MediaRef) => {
837
+ setRefs((prev) =>
838
+ prev.length >= MAX_SOURCE_IMAGES || prev.some((r) => r.uri === ref.uri) ? prev : [...prev, ref],
839
+ );
840
+ }, []);
841
+ const removeRef = useCallback((idx: number) => {
842
+ setRefs((prev) => prev.filter((_, i) => i !== idx));
843
+ }, []);
844
+
845
+ async function submit() {
846
+ const trimmed = name.trim();
847
+ if (!trimmed) {
848
+ setMsg("A name is required.");
849
+ return;
850
+ }
851
+ if (refs.length === 0) {
852
+ setMsg("At least one reference image is required — an identity keeps ≥1.");
853
+ return;
854
+ }
855
+ setBusy(true);
856
+ setMsg(null);
857
+ const res = await create(
858
+ trimmed,
859
+ refs.map((r) => r.uri),
860
+ notes.trim(),
861
+ );
862
+ setBusy(false);
863
+ if (res.ok) {
864
+ setName("");
865
+ setNotes("");
866
+ setRefs([]);
867
+ setMsg(`Created identity profile "${res.profile ? res.profile.name : trimmed}".`);
868
+ } else if (res.duplicate) {
869
+ setMsg(`An identity profile named "${trimmed}" already exists — choose another name.`);
870
+ } else {
871
+ setMsg(res.message || "Could not create the identity profile.");
872
+ }
873
+ }
874
+
875
+ const canSubmit = name.trim().length > 0 && refs.length > 0 && !busy;
876
+
877
+ return (
878
+ <section className="vi-comfy-bar" aria-label="Create an identity profile" style={{ marginTop: "0.8rem" }}>
879
+ <label className="vi-comfy-field">
880
+ <span className="vi-comfy-label">Name</span>
881
+ <input
882
+ type="text"
883
+ className="vi-knob-input"
884
+ value={name}
885
+ placeholder="e.g. Mira"
886
+ disabled={busy}
887
+ onChange={(e) => setName(e.target.value)}
888
+ />
889
+ </label>
890
+ <label className="vi-comfy-field" style={{ flexBasis: "24rem" }}>
891
+ <span className="vi-comfy-label">Notes (optional)</span>
892
+ <textarea
893
+ className="vi-knob-input"
894
+ rows={1}
895
+ value={notes}
896
+ placeholder="what makes this identity, continuity notes, etc."
897
+ disabled={busy}
898
+ onChange={(e) => setNotes(e.target.value)}
899
+ />
900
+ </label>
901
+ <div style={{ flex: "1 1 100%" }}>
902
+ <span className="vi-comfy-label">Reference images</span>
903
+ <ReferenceEditor refs={refs.map((r) => r.uri)} onAdd={addRef} onRemove={removeRef} libraryImages={libraryImages} busy={busy} />
904
+ </div>
905
+ <div className="vi-comfy-bar-actions vi-run-bar" style={{ flexWrap: "wrap" }}>
906
+ <button type="button" className="vi-btn vi-btn-accent" disabled={!canSubmit} onClick={() => void submit()}>
907
+ {busy ? "Creating…" : "Create identity profile"}
908
+ </button>
909
+ {msg && (
910
+ <p className="vi-comfy-hint" role="status" style={{ flexBasis: "100%", margin: 0 }}>
911
+ {msg}
912
+ </p>
913
+ )}
914
+ </div>
915
+ </section>
916
+ );
917
+ }
918
+
919
+ // ── one generated turnaround VIEW tile (Legacy mode) ────────────────────────
920
+ const RECON_TILE = "4.6rem";
921
+ function ReconViewTile({
922
+ relPath,
923
+ label,
924
+ canonical,
925
+ selected,
926
+ busy,
927
+ onToggle,
928
+ onRegenerate,
929
+ }: {
930
+ relPath: string;
931
+ label: string;
932
+ canonical: boolean;
933
+ selected: boolean;
934
+ busy: boolean;
935
+ onToggle: () => void;
936
+ onRegenerate: () => void;
937
+ }) {
938
+ const [state, setState] = useState<"loading" | "ok" | "error">("loading");
939
+ return (
940
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.2rem", alignItems: "center", width: RECON_TILE }}>
941
+ <div
942
+ style={{
943
+ position: "relative",
944
+ width: RECON_TILE,
945
+ height: RECON_TILE,
946
+ borderRadius: "0.35rem",
947
+ overflow: "hidden",
948
+ background: "#000",
949
+ border: selected ? "2px solid var(--vi-accent, #6aa8ff)" : "1px solid var(--vi-border)",
950
+ }}
951
+ >
952
+ {state !== "error" && (
953
+ <img
954
+ src={mediaBytesUrl(relPath)}
955
+ alt={label}
956
+ onLoad={() => setState("ok")}
957
+ onError={() => setState("error")}
958
+ style={{
959
+ width: "100%",
960
+ height: "100%",
961
+ objectFit: "cover",
962
+ opacity: state === "ok" ? 1 : 0,
963
+ transition: "opacity 0.2s ease",
964
+ }}
965
+ />
966
+ )}
967
+ {state === "loading" && (
968
+ <span
969
+ className="vi-comfy-hint"
970
+ style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.7rem", opacity: 0.6 }}
971
+ >
972
+ …
973
+ </span>
974
+ )}
975
+ {state === "error" && (
976
+ <span
977
+ className="vi-comfy-hint"
978
+ title="This view failed to load."
979
+ style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.8rem", opacity: 0.85 }}
980
+ >
981
+ ⚠
982
+ </span>
983
+ )}
984
+ {canonical && (
985
+ <span title="in the canonical set" style={{ position: "absolute", top: 2, right: 3, fontSize: "0.75rem", lineHeight: 1, textShadow: "0 0 3px #000" }}>
986
+ ★
987
+ </span>
988
+ )}
989
+ </div>
990
+ <span className="vi-comfy-hint" style={{ fontSize: "0.65rem", opacity: 0.85, lineHeight: 1.1 }}>
991
+ {label}
992
+ </span>
993
+ <label style={{ display: "flex", alignItems: "center", gap: "0.2rem", fontSize: "0.65rem", cursor: busy ? "default" : "pointer" }}>
994
+ <input type="checkbox" checked={selected} disabled={busy} onChange={onToggle} /> pick
995
+ </label>
996
+ <button
997
+ type="button"
998
+ className="vi-btn vi-btn-sm vi-btn-ghost"
999
+ disabled={busy}
1000
+ onClick={onRegenerate}
1001
+ title={`Regenerate the ${label} view`}
1002
+ style={{ fontSize: "0.65rem", padding: "0 0.35rem" }}
1003
+ >
1004
+ ↻ redo
1005
+ </button>
1006
+ </div>
1007
+ );
1008
+ }
1009
+
1010
+ // ── SETTINGS panel (IDENTITY-VERSIONS-SLICE.md §2) — per-identity generation
1011
+ // defaults: texture / pose / turntable geometry / auto-promote / background
1012
+ // removal / front-reference pick. Local DRAFT + explicit Save (the same idiom
1013
+ // the card's own name/notes/refs edit already uses — house-consistent, and
1014
+ // simpler than a debounce timer), so a mid-edit save-mistake is never a silent
1015
+ // partial-PATCH surprise. Opening the panel resets the draft to the profile's
1016
+ // current gen_settings, discarding any unsaved edits from a prior open. ────────
1017
+ function SettingsPanel({
1018
+ profile,
1019
+ saveSettings,
1020
+ }: {
1021
+ profile: IdentityProfile;
1022
+ saveSettings: IdentityProfilesState["saveSettings"];
1023
+ }) {
1024
+ const [draft, setDraft] = useState<GenSettings>(profile.gen_settings);
1025
+ const [busy, setBusy] = useState(false);
1026
+ const [msg, setMsg] = useState<string | null>(null);
1027
+
1028
+ // The image-text-to-text models the fleet actually advertises, for the vision-model
1029
+ // picker — same registry source + stale-while-revalidate posture as the Movie
1030
+ // director's judge-VLM select. "" (Default) maps to gen_settings.vision_model = null,
1031
+ // i.e. the fleet-default VL model (the 3B). The list shows what's SERVABLE (never a
1032
+ // typed string), and the server re-validates the key against the live registry.
1033
+ const {
1034
+ models: visionModels,
1035
+ loading: visionLoading,
1036
+ error: visionError,
1037
+ refresh: refreshVision,
1038
+ } = useModelsByTask(VISION_TASK);
1039
+
1040
+ async function save() {
1041
+ setBusy(true);
1042
+ setMsg(null);
1043
+ const res = await saveSettings(profile.slug, draft);
1044
+ setBusy(false);
1045
+ setMsg(res.ok ? "Saved." : res.message || "Could not save settings.");
1046
+ }
1047
+
1048
+ return (
1049
+ <section
1050
+ className="vi-comfy-bar vi-id-settings"
1051
+ aria-label={`Generation settings for ${profile.name}`}
1052
+ style={{
1053
+ marginTop: "0.3rem",
1054
+ padding: "0.5rem",
1055
+ borderRadius: "0.4rem",
1056
+ border: "1px solid var(--vi-border)",
1057
+ display: "flex",
1058
+ flexDirection: "column",
1059
+ gap: "0.4rem",
1060
+ }}
1061
+ >
1062
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "1rem" }}>
1063
+ <label style={{ display: "flex", alignItems: "center", gap: "0.35rem", fontSize: "0.85rem" }}>
1064
+ <input
1065
+ type="checkbox"
1066
+ checked={draft.texture}
1067
+ disabled={busy}
1068
+ onChange={(e) => setDraft((d) => ({ ...d, texture: e.target.checked }))}
1069
+ />
1070
+ Texture
1071
+ </label>
1072
+ <label style={{ display: "flex", alignItems: "center", gap: "0.35rem", fontSize: "0.85rem" }}>
1073
+ <input
1074
+ type="checkbox"
1075
+ checked={draft.auto_promote}
1076
+ disabled={busy}
1077
+ onChange={(e) => setDraft((d) => ({ ...d, auto_promote: e.target.checked }))}
1078
+ />
1079
+ Auto-promote canonical
1080
+ </label>
1081
+ <label style={{ display: "flex", alignItems: "center", gap: "0.35rem", fontSize: "0.85rem" }}>
1082
+ <input
1083
+ type="checkbox"
1084
+ checked={draft.remove_background}
1085
+ disabled={busy}
1086
+ onChange={(e) => setDraft((d) => ({ ...d, remove_background: e.target.checked }))}
1087
+ />
1088
+ Remove background
1089
+ </label>
1090
+ </div>
1091
+
1092
+ <label className="vi-comfy-field" style={{ flexBasis: "12rem" }}>
1093
+ <span className="vi-comfy-label">Pose</span>
1094
+ <select
1095
+ className="vi-knob-select"
1096
+ value={draft.pose}
1097
+ disabled={busy}
1098
+ onChange={(e) => setDraft((d) => ({ ...d, pose: e.target.value === "t-pose" ? "t-pose" : "none" }))}
1099
+ >
1100
+ <option value="none">None</option>
1101
+ <option value="t-pose">T-pose</option>
1102
+ </select>
1103
+ <span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
1104
+ T-pose normalizes the character&apos;s stance before meshing — clears occlusion ambiguity.
1105
+ </span>
1106
+ </label>
1107
+
1108
+ <label className="vi-comfy-field" style={{ flexBasis: "20rem" }}>
1109
+ <span className="vi-comfy-label">Vision model</span>
1110
+ <span style={{ display: "flex", alignItems: "center", gap: "0.35rem" }}>
1111
+ <select
1112
+ className="vi-knob-select"
1113
+ style={{ flex: 1 }}
1114
+ value={draft.vision_model ?? ""}
1115
+ disabled={busy || visionLoading}
1116
+ onChange={(e) =>
1117
+ setDraft((d) => ({ ...d, vision_model: e.target.value || null }))
1118
+ }
1119
+ >
1120
+ {/* Default (null/empty) is always the first, always-selectable option.
1121
+ Server-side AUTO: prefers a 7B VL when the fleet has one, else the
1122
+ fleet default (3B). */}
1123
+ <option value="">Auto (7B if available, else fleet default)</option>
1124
+ {/* Keep a saved value selectable even if it isn't in the current list
1125
+ (registry still loading, or the model went off-disk). */}
1126
+ {draft.vision_model &&
1127
+ !visionModels.some((m) => m.id === draft.vision_model) && (
1128
+ <option value={draft.vision_model}>{draft.vision_model}</option>
1129
+ )}
1130
+ {visionModels.map((m) => (
1131
+ <option key={m.id} value={m.id}>
1132
+ {m.label}
1133
+ </option>
1134
+ ))}
1135
+ </select>
1136
+ <button
1137
+ type="button"
1138
+ className="vi-btn vi-btn-sm vi-btn-ghost"
1139
+ onClick={refreshVision}
1140
+ disabled={busy || visionLoading}
1141
+ title="Re-scan the registry for image-text-to-text vision models"
1142
+ >
1143
+ ↻
1144
+ </button>
1145
+ </span>
1146
+ <span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
1147
+ The vision-language model used to pick the full-body reference before meshing.
1148
+ Auto prefers a 7B when installed (falls back to the 3B); pick a model to pin one.
1149
+ </span>
1150
+ {visionError && <span className="vi-error">{visionError}</span>}
1151
+ </label>
1152
+
1153
+ {/* CLEANUP-PROMPT slice (C4 — operator 2026-07-15): "possibly these are wired but
1154
+ not set... id like pregen prompting... to be facilitated" — makes the C1-C3
1155
+ plumbed cleanup_prompt/negative_prompt fields reachable. cleanup_prompt is a
1156
+ POSITIVE-worded avoid instruction woven into the T-pose front render (the mesh
1157
+ path); negative_prompt is a TRUE negative forwarded to the studio Wan-VACE
1158
+ render (reconstruction + the mesh route's id_lock front render). Both persist
1159
+ via the same gen_settings PATCH every other Advanced field above uses; both
1160
+ default "" — byte-identical to today until set (defaults-are-promises). */}
1161
+ <label className="vi-comfy-field" style={{ flexBasis: "20rem" }}>
1162
+ <span className="vi-comfy-label">Avoid / cleanup</span>
1163
+ <input
1164
+ type="text"
1165
+ className="vi-knob-input"
1166
+ style={{ width: "100%" }}
1167
+ value={draft.cleanup_prompt}
1168
+ disabled={busy}
1169
+ placeholder="e.g. no object on back, no logos, clean bare back"
1170
+ onChange={(e) => setDraft((d) => ({ ...d, cleanup_prompt: e.target.value }))}
1171
+ />
1172
+ <span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
1173
+ A positive-worded instruction woven into the T-pose front render so the meshed
1174
+ front never carries an unwanted prop/symbol. Empty = no change from today.
1175
+ </span>
1176
+ </label>
1177
+
1178
+ <label className="vi-comfy-field" style={{ flexBasis: "20rem" }}>
1179
+ <span className="vi-comfy-label">Negative prompt</span>
1180
+ <input
1181
+ type="text"
1182
+ className="vi-knob-input"
1183
+ style={{ width: "100%" }}
1184
+ value={draft.negative_prompt}
1185
+ disabled={busy}
1186
+ placeholder="e.g. backpack, symbols, logo"
1187
+ onChange={(e) => setDraft((d) => ({ ...d, negative_prompt: e.target.value }))}
1188
+ />
1189
+ <span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
1190
+ A true negative forwarded to the render engine alongside the Avoid/cleanup text.
1191
+ Optional — most cases only need Avoid/cleanup above.
1192
+ </span>
1193
+ </label>
1194
+
1195
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.6rem" }}>
1196
+ <label className="vi-comfy-field" style={{ flexBasis: "6rem" }}>
1197
+ <span className="vi-comfy-label">Frames</span>
1198
+ <input
1199
+ type="number"
1200
+ className="vi-knob-input"
1201
+ min={1}
1202
+ value={draft.frame_count}
1203
+ disabled={busy}
1204
+ onChange={(e) => setDraft((d) => ({ ...d, frame_count: Number(e.target.value) || d.frame_count }))}
1205
+ />
1206
+ </label>
1207
+ <label className="vi-comfy-field" style={{ flexBasis: "6rem" }}>
1208
+ <span className="vi-comfy-label">FPS</span>
1209
+ <input
1210
+ type="number"
1211
+ className="vi-knob-input"
1212
+ min={1}
1213
+ value={draft.fps}
1214
+ disabled={busy}
1215
+ onChange={(e) => setDraft((d) => ({ ...d, fps: Number(e.target.value) || d.fps }))}
1216
+ />
1217
+ </label>
1218
+ <label className="vi-comfy-field" style={{ flexBasis: "6rem" }}>
1219
+ <span className="vi-comfy-label">Width</span>
1220
+ <input
1221
+ type="number"
1222
+ className="vi-knob-input"
1223
+ min={64}
1224
+ step={64}
1225
+ value={draft.width}
1226
+ disabled={busy}
1227
+ onChange={(e) => setDraft((d) => ({ ...d, width: Number(e.target.value) || d.width }))}
1228
+ />
1229
+ </label>
1230
+ <label className="vi-comfy-field" style={{ flexBasis: "6rem" }}>
1231
+ <span className="vi-comfy-label">Height</span>
1232
+ <input
1233
+ type="number"
1234
+ className="vi-knob-input"
1235
+ min={64}
1236
+ step={64}
1237
+ value={draft.height}
1238
+ disabled={busy}
1239
+ onChange={(e) => setDraft((d) => ({ ...d, height: Number(e.target.value) || d.height }))}
1240
+ />
1241
+ </label>
1242
+ </div>
1243
+
1244
+ <div>
1245
+ <span className="vi-comfy-label">Front reference</span>
1246
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.35rem", marginTop: "0.3rem", alignItems: "center" }}>
1247
+ <button
1248
+ type="button"
1249
+ className={`vi-btn vi-btn-sm ${draft.front_ref == null ? "vi-btn-accent" : "vi-btn-ghost"}`}
1250
+ disabled={busy}
1251
+ onClick={() => setDraft((d) => ({ ...d, front_ref: null }))}
1252
+ title="Let the pipeline pick the best source photo as the mesh front"
1253
+ >
1254
+ Auto
1255
+ </button>
1256
+ {profile.reference_images.map((uri, idx) => (
1257
+ <button
1258
+ type="button"
1259
+ key={`${uri}_${idx}`}
1260
+ disabled={busy}
1261
+ onClick={() => setDraft((d) => ({ ...d, front_ref: uri }))}
1262
+ title={`Use reference ${idx + 1} as the mesh front`}
1263
+ style={{
1264
+ padding: 0,
1265
+ border: draft.front_ref === uri ? "2px solid var(--vi-accent, #6aa8ff)" : "1px solid var(--vi-border)",
1266
+ borderRadius: "0.35rem",
1267
+ overflow: "hidden",
1268
+ background: "none",
1269
+ cursor: busy ? "default" : "pointer",
1270
+ lineHeight: 0,
1271
+ }}
1272
+ >
1273
+ <img
1274
+ src={mediaBytesUrl(uri)}
1275
+ alt={`reference ${idx + 1}`}
1276
+ style={{ width: "2.6rem", height: "2.6rem", objectFit: "cover", display: "block" }}
1277
+ />
1278
+ </button>
1279
+ ))}
1280
+ </div>
1281
+ </div>
1282
+
1283
+ <div className="vi-comfy-bar-actions" style={{ alignItems: "center" }}>
1284
+ <button type="button" className="vi-btn vi-btn-sm vi-btn-accent" disabled={busy} onClick={() => void save()}>
1285
+ {busy ? "Saving…" : "Save settings"}
1286
+ </button>
1287
+ {msg && (
1288
+ <span className="vi-comfy-hint" role="status" style={{ opacity: 0.85 }}>
1289
+ {msg}
1290
+ </span>
1291
+ )}
1292
+ </div>
1293
+ </section>
1294
+ );
1295
+ }
1296
+
1297
+ // ── one VERSION row (IDENTITY-VERSIONS-SLICE.md §1/§4) — name, kind badge,
1298
+ // created date, ACTIVE marker; Activate / Rename (inline) / Archive (confirm;
1299
+ // disabled for the base clay version + the active version, tooltip says why).
1300
+ // Never-delete lives at this layer now — Archive reads "archive", not "delete",
1301
+ // and the confirm says so. `isBase` is inferred from `kind === "clay"` (the
1302
+ // wire contract carries no explicit is-base flag; the design doc says the clay
1303
+ // base is "always kept, never replaced silently" and is minted once per
1304
+ // identity, so kind alone is the stable signal today). ─────────────────────
1305
+ function VersionRow({
1306
+ version,
1307
+ isActive,
1308
+ isBase,
1309
+ onActivate,
1310
+ onRename,
1311
+ onArchive,
1312
+ }: {
1313
+ version: IdentityVersion;
1314
+ isActive: boolean;
1315
+ isBase: boolean;
1316
+ onActivate: () => Promise<{ ok: boolean; message?: string }>;
1317
+ onRename: (name: string) => Promise<{ ok: boolean; message?: string }>;
1318
+ onArchive: () => Promise<{ ok: boolean; message?: string }>;
1319
+ }) {
1320
+ const [busy, setBusy] = useState(false);
1321
+ const [renaming, setRenaming] = useState(false);
1322
+ const [draftName, setDraftName] = useState(version.name);
1323
+ const [msg, setMsg] = useState<string | null>(null);
1324
+
1325
+ const archiveDisabled = isActive || isBase;
1326
+ const archiveTitle = isActive
1327
+ ? "Can't archive — this is the active version."
1328
+ : isBase
1329
+ ? "Can't archive — this is the base (clay) version."
1330
+ : "Archive this version (recoverable, never erased).";
1331
+
1332
+ async function doActivate() {
1333
+ setBusy(true);
1334
+ setMsg(null);
1335
+ const res = await onActivate();
1336
+ setBusy(false);
1337
+ if (!res.ok) setMsg(res.message || "Could not activate this version.");
1338
+ }
1339
+ async function doRename() {
1340
+ const trimmed = draftName.trim();
1341
+ if (!trimmed) {
1342
+ setMsg("A name is required.");
1343
+ return;
1344
+ }
1345
+ setBusy(true);
1346
+ setMsg(null);
1347
+ const res = await onRename(trimmed);
1348
+ setBusy(false);
1349
+ if (res.ok) {
1350
+ setRenaming(false);
1351
+ } else {
1352
+ setMsg(res.message || "Could not rename this version.");
1353
+ }
1354
+ }
1355
+ async function doArchive() {
1356
+ if (!window.confirm(`Archive version "${version.name || version.version_id}"? Recoverable, never erased.`)) return;
1357
+ setBusy(true);
1358
+ setMsg(null);
1359
+ const res = await onArchive();
1360
+ setBusy(false);
1361
+ if (!res.ok) setMsg(res.message || "Could not archive this version.");
1362
+ }
1363
+
1364
+ const { short: createdShort, full: createdFull } = fmtCreated(version.created_at);
1365
+
1366
+ return (
1367
+ <div
1368
+ style={{
1369
+ display: "flex",
1370
+ flexWrap: "wrap",
1371
+ alignItems: "center",
1372
+ gap: "0.5rem",
1373
+ padding: "0.35rem 0.5rem",
1374
+ borderRadius: "0.35rem",
1375
+ border: "1px solid var(--vi-border)",
1376
+ background: isActive ? "rgba(106,168,255,0.08)" : "transparent",
1377
+ }}
1378
+ >
1379
+ {renaming ? (
1380
+ <>
1381
+ <input
1382
+ type="text"
1383
+ className="vi-knob-input"
1384
+ style={{ flex: "1 1 8rem" }}
1385
+ value={draftName}
1386
+ disabled={busy}
1387
+ onChange={(e) => setDraftName(e.target.value)}
1388
+ />
1389
+ <button type="button" className="vi-btn vi-btn-sm vi-btn-accent" disabled={busy} onClick={() => void doRename()}>
1390
+ {busy ? "Saving…" : "Save"}
1391
+ </button>
1392
+ <button
1393
+ type="button"
1394
+ className="vi-btn vi-btn-sm vi-btn-ghost"
1395
+ disabled={busy}
1396
+ onClick={() => {
1397
+ setRenaming(false);
1398
+ setDraftName(version.name);
1399
+ setMsg(null);
1400
+ }}
1401
+ >
1402
+ Cancel
1403
+ </button>
1404
+ </>
1405
+ ) : (
1406
+ <>
1407
+ <span style={{ flex: "1 1 8rem", fontSize: "0.85rem" }}>
1408
+ {version.name || version.version_id}
1409
+ {isBase && (
1410
+ <span
1411
+ title="the clay base — the identity's geometric ground truth, always kept and never archivable"
1412
+ style={{ marginLeft: "0.4rem", fontSize: "0.7rem", opacity: 0.85 }}
1413
+ >
1414
+ 📌 base
1415
+ </span>
1416
+ )}
1417
+ {isActive && (
1418
+ <span title="the active version" style={{ marginLeft: "0.4rem", fontSize: "0.7rem", opacity: 0.85 }}>
1419
+ ● ACTIVE
1420
+ </span>
1421
+ )}
1422
+ </span>
1423
+ <span
1424
+ className="vi-comfy-hint"
1425
+ title={`kind: ${version.kind}`}
1426
+ style={{ padding: "0.05rem 0.4rem", borderRadius: "0.3rem", border: "1px solid var(--vi-border)", fontSize: "0.7rem" }}
1427
+ >
1428
+ {version.kind}
1429
+ </span>
1430
+ {createdShort && (
1431
+ <span className="vi-comfy-hint" title={createdFull} style={{ fontSize: "0.7rem", opacity: 0.7 }}>
1432
+ {createdShort}
1433
+ </span>
1434
+ )}
1435
+ <button
1436
+ type="button"
1437
+ className="vi-btn vi-btn-sm vi-btn-ghost"
1438
+ disabled={busy || isActive}
1439
+ onClick={() => void doActivate()}
1440
+ title={isActive ? "Already the active version" : "Make this the active version"}
1441
+ >
1442
+ {isActive ? "Active" : busy ? "Activating…" : "Activate"}
1443
+ </button>
1444
+ <button
1445
+ type="button"
1446
+ className="vi-btn vi-btn-sm vi-btn-ghost"
1447
+ disabled={busy}
1448
+ onClick={() => {
1449
+ setDraftName(version.name);
1450
+ setMsg(null);
1451
+ setRenaming(true);
1452
+ }}
1453
+ >
1454
+ Rename
1455
+ </button>
1456
+ <button
1457
+ type="button"
1458
+ className="vi-btn vi-btn-sm vi-btn-ghost"
1459
+ disabled={busy || archiveDisabled}
1460
+ title={archiveTitle}
1461
+ onClick={() => void doArchive()}
1462
+ >
1463
+ Archive
1464
+ </button>
1465
+ </>
1466
+ )}
1467
+ {msg && (
1468
+ <span className="vi-error" role="alert" style={{ flexBasis: "100%", fontSize: "0.75rem" }}>
1469
+ {msg}
1470
+ </span>
1471
+ )}
1472
+ </div>
1473
+ );
1474
+ }
1475
+
1476
+ // ── VERSIONS list (IDENTITY-VERSIONS-SLICE.md §1/§4) — the identity's clay base
1477
+ // + N accrued render-sets. Reads straight off the profile's `versions[]` /
1478
+ // `active_version` (both live off GET /video/identity-profiles). The clay base is
1479
+ // pinned + visually marked and is never archivable (VersionRow disables Archive for
1480
+ // it and the active version). Hidden entirely until a first Generate mints the base
1481
+ // version, so a never-generated identity shows no empty section. ────────────────
1482
+ function VersionsPanel({
1483
+ profile,
1484
+ activateVersion,
1485
+ renameVersion,
1486
+ archiveVersion,
1487
+ }: {
1488
+ profile: IdentityProfile;
1489
+ activateVersion: IdentityProfilesState["activateVersion"];
1490
+ renameVersion: IdentityProfilesState["renameVersion"];
1491
+ archiveVersion: IdentityProfilesState["archiveVersion"];
1492
+ }) {
1493
+ const versions = profile.versions ?? [];
1494
+ if (versions.length === 0) return null;
1495
+ return (
1496
+ <div style={{ marginTop: "0.6rem" }}>
1497
+ <p className="vi-comfy-label" style={{ margin: "0 0 0.3rem" }}>
1498
+ Versions ({versions.length})
1499
+ </p>
1500
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.35rem" }}>
1501
+ {versions.map((v) => {
1502
+ // The clay base is identifiable on the wire by name "base" + kind "clay"
1503
+ // (the backend strips the internal is-base flag from _public_version). A
1504
+ // renamed base still can't be archived — the server refuses it regardless
1505
+ // (honest 400 surfaced in the row) — so name+kind is the display signal only.
1506
+ const isBase = v.kind === "clay" && v.name === "base";
1507
+ const isActive = profile.active_version != null && profile.active_version === v.version_id;
1508
+ return (
1509
+ <VersionRow
1510
+ key={v.version_id}
1511
+ version={v}
1512
+ isActive={isActive}
1513
+ isBase={isBase}
1514
+ onActivate={() => activateVersion(profile.slug, v.version_id)}
1515
+ onRename={(name) => renameVersion(profile.slug, v.version_id, name)}
1516
+ onArchive={() => archiveVersion(profile.slug, v.version_id)}
1517
+ />
1518
+ );
1519
+ })}
1520
+ </div>
1521
+ <p className="vi-comfy-hint" style={{ margin: "0.3rem 0 0", opacity: 0.65 }}>
1522
+ The clay base is the geometric ground truth — always kept, never archivable. Generating
1523
+ with texture lands a new version rather than overwriting.
1524
+ </p>
1525
+ </div>
1526
+ );
1527
+ }
1528
+
1529
+ // ── one profile CARD — display + inline edit + archive + character-sheet ────────
1530
+ // ── IdentityDetail — the ONE selected identity's full detail (was ProfileCard) ─
1531
+ // Body logic (state, the desync-safe active-version/active-recon resolution, the
1532
+ // generate→poll flow, promote/approve, save/archive) is UNCHANGED from the old
1533
+ // per-card version; only the RETURN JSX is restructured into the operator's
1534
+ // wireframe (header version picker · turntable + approved 3D · uploaded refs +
1535
+ // canonical 8 · generate-360). `tab` decides whether the settings surface leads
1536
+ // (the "settings" sidebar tab); `onTouch` marks this identity touched-this-session
1537
+ // when it's generated/edited so the "session" list stays honest.
1538
+ function IdentityDetail({
1539
+ profile,
1540
+ tab,
1541
+ update,
1542
+ remove,
1543
+ reload,
1544
+ libraryImages,
1545
+ saveSettings,
1546
+ activateVersion,
1547
+ renameVersion,
1548
+ archiveVersion,
1549
+ onTouch,
1550
+ }: {
1551
+ profile: IdentityProfile;
1552
+ tab: IdTab;
1553
+ update: IdentityProfilesState["update"];
1554
+ remove: IdentityProfilesState["remove"];
1555
+ reload: IdentityProfilesState["reload"];
1556
+ libraryImages: LibraryItem[];
1557
+ saveSettings: IdentityProfilesState["saveSettings"];
1558
+ activateVersion: IdentityProfilesState["activateVersion"];
1559
+ renameVersion: IdentityProfilesState["renameVersion"];
1560
+ archiveVersion: IdentityProfilesState["archiveVersion"];
1561
+ onTouch: (slug: string) => void;
1562
+ }) {
1563
+ const [editing, setEditing] = useState(false);
1564
+ const [name, setName] = useState(profile.name);
1565
+ const [notes, setNotes] = useState(profile.notes ?? "");
1566
+ const [refs, setRefs] = useState<string[]>(profile.reference_images);
1567
+ const [busy, setBusy] = useState(false);
1568
+ const [msg, setMsg] = useState<string | null>(null);
1569
+
1570
+ // ── SETTINGS (IDENTITY-VERSIONS-SLICE.md §2). In the t17 master-detail layout
1571
+ // the settings surface is driven by the sidebar "settings" tab (leads the
1572
+ // detail) and by the "⚙ Settings & advanced" disclosure on the other tabs —
1573
+ // so there is no longer a per-card settingsOpen toggle to track here.
1574
+ const recon = useReconstruction(profile.slug, reload);
1575
+ const [sheetOpen, setSheetOpen] = useState(false);
1576
+ const [sheetMode, setSheetMode] = useState<"sheet" | "turntable" | "angle-ring">("sheet");
1577
+ const [sheetPrompt, setSheetPrompt] = useState(profile.notes ?? "");
1578
+ const [sheetSeed, setSheetSeed] = useState("");
1579
+ const [selected, setSelected] = useState<Set<number>>(new Set());
1580
+ const [promoting, setPromoting] = useState(false);
1581
+ const [promoteMsg, setPromoteMsg] = useState<string | null>(null);
1582
+
1583
+ // ── ONE-CLICK "Generate 3D identity" (mesh -> 360° turntable -> canonical) ──
1584
+ const [genBusy, setGenBusy] = useState(false);
1585
+ const [genStatus, setGenStatus] = useState<string | null>(null);
1586
+ // Honest degrade surface for pose="t-pose" when the render stage isn't capable on
1587
+ // this deployment — the /generate response carries a structured `pose` notice, which
1588
+ // we hold here (separate from the poll-churned genStatus) so it stays visible.
1589
+ const [poseNotice, setPoseNotice] = useState<string | null>(null);
1590
+ // Legacy 2D render actions (angle ring / character sheet / orbit-clip 360) are
1591
+ // SUPERSEDED by the one-click mesh path for the identity gen flow — kept behind
1592
+ // the "⚙ Settings & advanced" disclosure (see the render) so the tab stays to
1593
+ // the point.
1594
+ const genTimer = useRef<number | null>(null);
1595
+ const genActive = useRef(false);
1596
+ // Stop the poll loop + free the timer when this card unmounts mid-build.
1597
+ useEffect(() => {
1598
+ return () => {
1599
+ genActive.current = false;
1600
+ if (genTimer.current != null) window.clearTimeout(genTimer.current);
1601
+ };
1602
+ }, []);
1603
+
1604
+ const recons = profile.reconstructions ?? [];
1605
+ const latestRecon = recons.length
1606
+ ? recons.reduce((a, b) => ((b.created_at ?? 0) >= (a.created_at ?? 0) ? b : a))
1607
+ : null;
1608
+ // The reconstruction the card should DISPLAY: the one bound to the ACTIVE version
1609
+ // (version.recon_id -> reconstructions[]), NOT "newest by date". Prior code used
1610
+ // latestRecon everywhere, so activating base/clay-03/clay-04 never repointed the
1611
+ // views panel or the 3D mesh viewer — they stuck to whatever was generated last.
1612
+ // Fall back to latestRecon when there is no active version, or its recon_id names
1613
+ // nothing on the list (a versionless/legacy profile, or a version whose recon was
1614
+ // pruned) — so those profiles behave exactly as before (defaults-are-promises).
1615
+ const activeVersion = (profile.versions ?? []).find(
1616
+ (v) => profile.active_version != null && v.version_id === profile.active_version,
1617
+ );
1618
+ const activeRecon =
1619
+ (activeVersion?.recon_id
1620
+ ? recons.find((r) => r.recon_id === activeVersion.recon_id)
1621
+ : null) ?? latestRecon;
1622
+ const canonicalPaths = profile.canonical ?? [];
1623
+ const canonicalSet = new Set(canonicalPaths);
1624
+
1625
+ function parsedSeed(): number | undefined {
1626
+ const t = sheetSeed.trim();
1627
+ if (t === "") return undefined;
1628
+ const n = Number(t);
1629
+ return Number.isFinite(n) ? n : undefined;
1630
+ }
1631
+ function generateSheet() {
1632
+ setSelected(new Set());
1633
+ setPromoteMsg(null);
1634
+ recon.generate({ mode: "sheet", prompt: sheetPrompt.trim() || undefined, views: [...DEFAULT_VIEWS], seed: parsedSeed() });
1635
+ }
1636
+ function generateTurntable() {
1637
+ setSelected(new Set());
1638
+ setPromoteMsg(null);
1639
+ recon.generate({ mode: "turntable", prompt: sheetPrompt.trim() || undefined, seed: parsedSeed() });
1640
+ }
1641
+ function generateAngleRing() {
1642
+ setSelected(new Set());
1643
+ setPromoteMsg(null);
1644
+ recon.generate({
1645
+ mode: "angle-ring",
1646
+ prompt: sheetPrompt.trim() || undefined,
1647
+ seed: parsedSeed(),
1648
+ angle_step_deg: 10,
1649
+ elevations_deg: [0]
1650
+ });
1651
+ }
1652
+ function openPanel(mode: "sheet" | "turntable" | "angle-ring") {
1653
+ if (sheetOpen && sheetMode === mode) {
1654
+ setSheetOpen(false);
1655
+ return;
1656
+ }
1657
+ setSheetMode(mode);
1658
+ setSheetOpen(true);
1659
+ }
1660
+ function regenerateView(idx: number) {
1661
+ setPromoteMsg(null);
1662
+ const view = DEFAULT_VIEWS[idx];
1663
+ recon.generate({ prompt: sheetPrompt.trim() || undefined, views: view ? [view] : undefined, seed: parsedSeed() });
1664
+ }
1665
+ function toggleSelected(idx: number) {
1666
+ setSelected((prev) => {
1667
+ const next = new Set(prev);
1668
+ if (next.has(idx)) next.delete(idx);
1669
+ else next.add(idx);
1670
+ return next;
1671
+ });
1672
+ }
1673
+ async function approveCanonical() {
1674
+ if (!activeRecon) return;
1675
+ const idxs = [...selected].sort((a, b) => a - b);
1676
+ if (idxs.length === 0) {
1677
+ setPromoteMsg("Pick at least one view to approve.");
1678
+ return;
1679
+ }
1680
+ setPromoting(true);
1681
+ setPromoteMsg(null);
1682
+ const res = await recon.promote(activeRecon.recon_id, idxs);
1683
+ setPromoting(false);
1684
+ if (res.ok) {
1685
+ setPromoteMsg("Promoted to canonical.");
1686
+ setSelected(new Set());
1687
+ reload();
1688
+ } else {
1689
+ setPromoteMsg(res.message || "Could not promote to canonical.");
1690
+ }
1691
+ }
1692
+ async function approveTurntable() {
1693
+ if (!activeRecon || activeRecon.mode !== "turntable") return;
1694
+ const n = activeRecon.views.length;
1695
+ if (n === 0) return;
1696
+ const idxs = Array.from(new Set([0, 1, 2, 3].map((k) => Math.min(n - 1, Math.round((k * n) / 4)))));
1697
+ setPromoting(true);
1698
+ setPromoteMsg(null);
1699
+ const res = await recon.promote(activeRecon.recon_id, idxs);
1700
+ setPromoting(false);
1701
+ if (res.ok) {
1702
+ setPromoteMsg(`Approved ${idxs.length} anchor view${idxs.length === 1 ? "" : "s"} to canonical.`);
1703
+ reload();
1704
+ } else {
1705
+ setPromoteMsg(res.message || "Could not approve to canonical.");
1706
+ }
1707
+ }
1708
+
1709
+ function startEdit() {
1710
+ setName(profile.name);
1711
+ setNotes(profile.notes ?? "");
1712
+ setRefs(profile.reference_images);
1713
+ setMsg(null);
1714
+ setEditing(true);
1715
+ }
1716
+ function cancelEdit() {
1717
+ setEditing(false);
1718
+ setMsg(null);
1719
+ }
1720
+ const addRef = useCallback((ref: MediaRef) => {
1721
+ setRefs((prev) => (prev.length >= MAX_SOURCE_IMAGES || prev.includes(ref.uri) ? prev : [...prev, ref.uri]));
1722
+ }, []);
1723
+ const removeRef = useCallback((idx: number) => {
1724
+ setRefs((prev) => prev.filter((_, i) => i !== idx));
1725
+ }, []);
1726
+
1727
+ async function save() {
1728
+ const trimmed = name.trim();
1729
+ if (!trimmed) {
1730
+ setMsg("A name is required.");
1731
+ return;
1732
+ }
1733
+ if (refs.length === 0) {
1734
+ setMsg("At least one reference image is required — an identity keeps ≥1.");
1735
+ return;
1736
+ }
1737
+ const fields: { name?: string; notes?: string; referenceImages?: string[] } = {};
1738
+ if (trimmed !== profile.name) fields.name = trimmed;
1739
+ if (notes.trim() !== (profile.notes ?? "")) fields.notes = notes.trim();
1740
+ const refsChanged = refs.length !== profile.reference_images.length || refs.some((r, i) => r !== profile.reference_images[i]);
1741
+ if (refsChanged) fields.referenceImages = refs;
1742
+ if (Object.keys(fields).length === 0) {
1743
+ setEditing(false);
1744
+ return;
1745
+ }
1746
+ setBusy(true);
1747
+ setMsg(null);
1748
+ const res = await update(profile.slug, fields);
1749
+ setBusy(false);
1750
+ if (res.ok) {
1751
+ setEditing(false);
1752
+ } else {
1753
+ setMsg(res.message || "Could not save changes.");
1754
+ }
1755
+ }
1756
+
1757
+ async function archive() {
1758
+ // Label says "Delete" (the verb users reach for — an "Archive"-only label read as
1759
+ // "no delete option exists"); the confirm keeps the never-delete truth visible.
1760
+ if (!window.confirm(`Delete identity profile "${profile.name}"? It is archived, not erased — recoverable.`)) return;
1761
+ setBusy(true);
1762
+ await remove(profile.slug);
1763
+ setBusy(false);
1764
+ }
1765
+
1766
+ // ONE ACTION → a complete 3D identity: POST /generate (bare body = the happy path:
1767
+ // chain the 360° turntable + auto-promote canonical), then poll the minted recon's
1768
+ // mesh state every ~5s (bounded ~20min) until done/error, and reload the list so the
1769
+ // new reconstruction + canonical show. Reuses the shared `request` transport helper.
1770
+ async function generateFullIdentity() {
1771
+ if (genBusy) return;
1772
+ onTouch(profile.slug); // generating counts as touching it this session
1773
+ setGenBusy(true);
1774
+ setGenStatus("Generating… (queued)");
1775
+ setPoseNotice(null);
1776
+ genActive.current = true;
1777
+
1778
+ // PREFILL from the identity's persisted gen_settings (IDENTITY-VERSIONS-SLICE.md
1779
+ // §2 — "settings … prefill the Generate click; the bare click still equals the
1780
+ // happy-path defaults"). DEFAULT_GEN_SETTINGS mirror the /generate route defaults,
1781
+ // so an unedited identity sends exactly what a bare {} would. remove_background is
1782
+ // persisted on the identity but NOT yet consumed by the /generate route, so it is
1783
+ // deliberately omitted from the body here (it takes effect once the route reads it).
1784
+ const gs = profile.gen_settings;
1785
+ const genBody: Record<string, unknown> = {
1786
+ texture: gs.texture,
1787
+ pose: gs.pose,
1788
+ auto_promote: gs.auto_promote,
1789
+ turntable: {
1790
+ frame_count: gs.frame_count,
1791
+ fps: gs.fps,
1792
+ width: gs.width,
1793
+ height: gs.height,
1794
+ },
1795
+ };
1796
+ // Explicit front-view pick → the route's `views.front` override (jailed server-side
1797
+ // to this profile's own images). Omitted when Auto, so the pipeline picks the front.
1798
+ if (gs.front_ref) genBody.views = { front: gs.front_ref };
1799
+ // Per-identity VISION MODEL → the route resolves body > gen_settings > null. Sent
1800
+ // only when set (mirrors front_ref); omitted == the fleet-default VL model. The route
1801
+ // ALSO falls back to the persisted gen_settings.vision_model, so a bare click honors
1802
+ // the identity's saved choice regardless.
1803
+ if (gs.vision_model) genBody.vision_model = gs.vision_model;
1804
+
1805
+ const res = await request<unknown>(identityGenerateUrl(profile.slug), {
1806
+ method: "POST",
1807
+ body: JSON.stringify(genBody),
1808
+ headers: { "Content-Type": "application/json" },
1809
+ meta: { specKey: "studio", operation: "identity.generate" },
1810
+ });
1811
+ if (!genActive.current) return;
1812
+ if (!res.ok) {
1813
+ genActive.current = false;
1814
+ setGenBusy(false);
1815
+ setGenStatus(describeAppError(errorOf(res)));
1816
+ return;
1817
+ }
1818
+ const enq = okValue(res) as {
1819
+ job_id?: string;
1820
+ recon_id?: string;
1821
+ pose?: { requested?: string; applied?: boolean; capable?: boolean; message?: string };
1822
+ };
1823
+ // Surface the honest T-pose degrade: when the render stage isn't capable, the build
1824
+ // still proceeds off the normal front and the response says so — show that note.
1825
+ if (enq.pose && enq.pose.applied === false) {
1826
+ setPoseNotice(
1827
+ enq.pose.message ||
1828
+ "T-pose normalization is not available on this deployment yet — generated from the source pose instead.",
1829
+ );
1830
+ } else if (enq.pose && enq.pose.applied === true) {
1831
+ setPoseNotice("T-pose normalization applied before meshing.");
1832
+ }
1833
+ const reconId = enq.recon_id;
1834
+ if (!reconId) {
1835
+ genActive.current = false;
1836
+ setGenBusy(false);
1837
+ setGenStatus("Malformed generate response.");
1838
+ return;
1839
+ }
1840
+
1841
+ const deadline = Date.now() + 20 * 60 * 1000; // ~20 min bound
1842
+ const pollOnce = async () => {
1843
+ if (!genActive.current) return;
1844
+ if (Date.now() > deadline) {
1845
+ genActive.current = false;
1846
+ setGenBusy(false);
1847
+ setGenStatus("Timed out waiting for the 3D identity build.");
1848
+ reload();
1849
+ return;
1850
+ }
1851
+ const sres = await request<unknown>(identityMeshStatusUrl(profile.slug, reconId), {
1852
+ meta: { specKey: "studio", operation: "identity.mesh.status" },
1853
+ });
1854
+ if (!genActive.current) return;
1855
+ if (sres.ok) {
1856
+ const ms = okValue(sres) as { status?: string; error?: string | null };
1857
+ const st = ms.status ?? "queued";
1858
+ if (st === "done") {
1859
+ genActive.current = false;
1860
+ setGenBusy(false);
1861
+ setGenStatus("done");
1862
+ reload();
1863
+ return;
1864
+ }
1865
+ if (st === "error" || st === "cancelled") {
1866
+ genActive.current = false;
1867
+ setGenBusy(false);
1868
+ setGenStatus(ms.error || `Build ${st}.`);
1869
+ reload();
1870
+ return;
1871
+ }
1872
+ setGenStatus(`Generating… (${st})`);
1873
+ }
1874
+ // queued/running/transient-poll-failure → keep polling.
1875
+ genTimer.current = window.setTimeout(() => void pollOnce(), 5000);
1876
+ };
1877
+ genTimer.current = window.setTimeout(() => void pollOnce(), 5000);
1878
+ }
1879
+
1880
+ const { short: createdShort, full: createdFull } = fmtCreated(profile.created_at);
1881
+
1882
+ // Wire-truth for the CENTREPIECE panels (constraint #1, reused verbatim from
1883
+ // the fixed ProfileCard logic above): the turntable + the "approved 3D id"
1884
+ // both drive off `activeRecon`, which resolves from the ACTIVE VERSION's
1885
+ // recon_id — never `latestRecon` unless there is no active version at all —
1886
+ // and the mesh gate checks the backend's real `mesh.status === "done"` +
1887
+ // `mesh.glb_path` (snake_case), never a wrong "completed"/glbUri flag.
1888
+ const turntableRecon =
1889
+ activeRecon && activeRecon.mode === "turntable" && (activeRecon.views as string[]).length > 0
1890
+ ? activeRecon
1891
+ : null;
1892
+ const meshDone =
1893
+ (activeRecon as any)?.mesh?.status === "done" && (activeRecon as any).mesh.glb_path
1894
+ ? ((activeRecon as any).mesh.glb_path as string)
1895
+ : null;
1896
+ const canApproveActive = activeRecon != null && !promoting && !recon.running;
1897
+
1898
+ // Approve the ACTIVE version: turntable → 4 anchor views; sheet/angle-ring →
1899
+ // the selected views (or all when none picked). One "✓ approve this version"
1900
+ // action fronting the same promote paths the legacy card exposed.
1901
+ async function approveActiveVersion() {
1902
+ if (!activeRecon) return;
1903
+ onTouch(profile.slug);
1904
+ if (activeRecon.mode === "turntable") {
1905
+ await approveTurntable();
1906
+ return;
1907
+ }
1908
+ if (selected.size > 0) {
1909
+ await approveCanonical();
1910
+ return;
1911
+ }
1912
+ // No explicit selection on a sheet/ring: promote every view of the active recon.
1913
+ const idxs = (activeRecon.views as string[]).map((_, i) => i);
1914
+ if (idxs.length === 0) {
1915
+ setPromoteMsg("This version has no views to approve yet.");
1916
+ return;
1917
+ }
1918
+ setPromoting(true);
1919
+ setPromoteMsg(null);
1920
+ const res = await recon.promote(activeRecon.recon_id, idxs);
1921
+ setPromoting(false);
1922
+ if (res.ok) {
1923
+ setPromoteMsg(`Approved ${idxs.length} view${idxs.length === 1 ? "" : "s"} to canonical.`);
1924
+ reload();
1925
+ } else {
1926
+ setPromoteMsg(res.message || "Could not approve this version.");
1927
+ }
1928
+ }
1929
+
1930
+ return (
1931
+ <article className="vi-identity-detail" style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
1932
+ {/* ── HEADER right side: VERSION picker + “+ new” (wireframe k/j). The name
1933
+ picker (i/h) lives in the station header; this owns versions + generate,
1934
+ which need the detail's poll state. ───────────────────────────────── */}
1935
+ <div className="vi-id-detail-head" style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", alignItems: "center" }}>
1936
+ <h3 style={{ margin: 0, fontSize: "1.05rem", flex: "0 0 auto" }}>{profile.name}</h3>
1937
+ <span className="vi-comfy-hint" title={createdFull} style={{ opacity: 0.7, fontSize: "0.75rem" }}>
1938
+ {createdShort ? `created ${createdShort}` : ""}
1939
+ </span>
1940
+ <span style={{ flex: 1 }} />
1941
+ <label className="vi-comfy-label" style={{ opacity: 0.75 }}>Version</label>
1942
+ <select
1943
+ className="vi-knob-input"
1944
+ style={{ flex: "0 1 18rem" }}
1945
+ value={profile.active_version ?? ""}
1946
+ disabled={(profile.versions ?? []).length === 0}
1947
+ onChange={(e) => { if (e.target.value) void activateVersion(profile.slug, e.target.value); }}
1948
+ title={(profile.versions ?? []).length === 0 ? "No versions yet — generate one" : "Switch the active version"}
1949
+ >
1950
+ {(profile.versions ?? []).length === 0 && <option value="">— no versions yet —</option>}
1951
+ {(profile.versions ?? []).map((v) => {
1952
+ const isBase = v.kind === "clay" && v.name === "base";
1953
+ return (
1954
+ <option key={v.version_id} value={v.version_id}>
1955
+ {v.name || v.version_id}{isBase ? " (base)" : ""}
1956
+ </option>
1957
+ );
1958
+ })}
1959
+ </select>
1960
+ <button
1961
+ type="button"
1962
+ className="vi-btn vi-btn-sm vi-btn-accent"
1963
+ disabled={genBusy || busy}
1964
+ onClick={() => void generateFullIdentity()}
1965
+ title="+ new version: build a 3D mesh, render a 360° turntable, and auto-promote canonical references"
1966
+ >
1967
+ {genBusy ? "Generating…" : "+ new"}
1968
+ </button>
1969
+ <button type="button" className="vi-btn vi-btn-sm vi-btn-ghost" disabled={busy || genBusy} onClick={editing ? cancelEdit : startEdit}>
1970
+ {editing ? "Close edit" : "Edit"}
1971
+ </button>
1972
+ <button type="button" className="vi-btn vi-btn-sm vi-btn-ghost" disabled={busy} onClick={() => void archive()}>
1973
+ {busy ? "Deleting…" : "Delete"}
1974
+ </button>
1975
+ </div>
1976
+
1977
+ {/* Inline edit (rename display-only + notes + refs) — opened from the header. */}
1978
+ {editing && (
1979
+ <div style={{ border: "1px solid var(--vi-border)", borderRadius: "0.5rem", padding: "0.6rem", display: "flex", flexDirection: "column", gap: "0.4rem" }}>
1980
+ <label className="vi-comfy-field">
1981
+ <span className="vi-comfy-label">Name</span>
1982
+ <input type="text" className="vi-knob-input" value={name} disabled={busy} onChange={(e) => setName(e.target.value)} />
1983
+ </label>
1984
+ <p className="vi-comfy-hint" style={{ margin: 0, opacity: 0.7 }}>
1985
+ Renaming only changes the display name — the saved slug (what templates/movies reference) never changes.
1986
+ </p>
1987
+ <label className="vi-comfy-field">
1988
+ <span className="vi-comfy-label">Notes</span>
1989
+ <textarea className="vi-knob-input" rows={2} value={notes} disabled={busy} onChange={(e) => setNotes(e.target.value)} />
1990
+ </label>
1991
+ {msg && <p className="vi-error" role="alert" style={{ margin: 0 }}>{msg}</p>}
1992
+ <div style={{ display: "flex", gap: "0.4rem" }}>
1993
+ <button type="button" className="vi-btn vi-btn-sm vi-btn-accent" disabled={busy} onClick={() => void save()}>
1994
+ {busy ? "Saving…" : "Save"}
1995
+ </button>
1996
+ <button type="button" className="vi-btn vi-btn-sm vi-btn-ghost" disabled={busy} onClick={cancelEdit}>Cancel</button>
1997
+ </div>
1998
+ </div>
1999
+ )}
2000
+
2001
+ {poseNotice && (
2002
+ <p className="vi-comfy-hint" role="status" style={{ margin: 0, opacity: 0.85 }}>{poseNotice}</p>
2003
+ )}
2004
+
2005
+ {/* The "settings" sidebar tab leads with the settings surface for this
2006
+ identity; other tabs keep it available under the Versions panel below. */}
2007
+ {tab === "settings" && <SettingsPanel profile={profile} saveSettings={saveSettings} />}
2008
+
2009
+ {/* ── CENTREPIECE (wireframe l/m/n/q): turntable (left) · approved 3D id
2010
+ for the ACTIVE version (right) · generate-360 under the turntable ·
2011
+ “✓ approve this version”. ─────────────────────────────────────────── */}
2012
+ <div className="vi-id-centre" style={{ display: "grid", gridTemplateColumns: "minmax(0, 440px) minmax(0, 1fr)", gap: "1rem", alignItems: "start" }}>
2013
+ {/* Turntable (drag to orbit) */}
2014
+ <div>
2015
+ <p className="vi-comfy-label" style={{ margin: "0 0 0.3rem" }}>Turntable {turntableRecon ? "(drag to orbit)" : ""}</p>
2016
+ <div style={{ width: "100%", maxWidth: 440, aspectRatio: "440 / 320", border: "1px solid var(--vi-border)", borderRadius: "0.5rem", overflow: "hidden", background: "#0b0b0b", display: "flex", alignItems: "center", justifyContent: "center" }}>
2017
+ {turntableRecon ? (
2018
+ <TurntableViewer
2019
+ frames={turntableRecon.views as string[]}
2020
+ degreesPerFrame={(turntableRecon as any).degrees_per_frame}
2021
+ frameCount={(turntableRecon as any).frame_count}
2022
+ />
2023
+ ) : activeRecon && activeRecon.mode === "angle-ring" ? (
2024
+ <div style={{ padding: "0.5rem", width: "100%" }}>
2025
+ <AngleRingEditor reconstruction={activeRecon as any} api={recon} />
2026
+ </div>
2027
+ ) : (
2028
+ <span className="vi-comfy-hint" style={{ opacity: 0.7, padding: "1rem", textAlign: "center" }}>
2029
+ No turntable for the active version yet — “⟳ generate 360” to build one.
2030
+ </span>
2031
+ )}
2032
+ </div>
2033
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.4rem", marginTop: "0.4rem", alignItems: "center" }}>
2034
+ <button
2035
+ type="button"
2036
+ className="vi-btn vi-btn-sm vi-btn-accent"
2037
+ disabled={genBusy || busy}
2038
+ onClick={() => void generateFullIdentity()}
2039
+ title="Build a 3D mesh, render a 360° turntable, and auto-promote canonical references"
2040
+ >
2041
+ {genBusy ? "Generating…" : "⟳ generate 360"}
2042
+ </button>
2043
+ {genStatus && (
2044
+ <span className="vi-comfy-hint" role="status" style={{ opacity: 0.85 }}>{genStatus}</span>
2045
+ )}
2046
+ </div>
2047
+ </div>
2048
+
2049
+ {/* Approved 3D id (active version) + ✓ approve this version */}
2050
+ <div>
2051
+ <p className="vi-comfy-label" style={{ margin: "0 0 0.3rem" }}>Approved 3D identity (active version)</p>
2052
+ <div style={{ width: "100%", maxWidth: 500, aspectRatio: "500 / 320", border: "1px solid var(--vi-border)", borderRadius: "0.5rem", overflow: "hidden", background: "#0b0b0b", display: "flex", alignItems: "center", justifyContent: "center" }}>
2053
+ {meshDone ? (
2054
+ <MeshViewer url={mediaBytesUrl(meshDone)} height={320} />
2055
+ ) : (
2056
+ <span className="vi-comfy-hint" style={{ opacity: 0.7, padding: "1rem", textAlign: "center" }}>
2057
+ No approved 3D mesh for the active version yet.
2058
+ </span>
2059
+ )}
2060
+ </div>
2061
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.4rem", marginTop: "0.4rem", alignItems: "center" }}>
2062
+ <button
2063
+ type="button"
2064
+ className="vi-btn vi-btn-sm vi-btn-accent"
2065
+ disabled={!canApproveActive}
2066
+ onClick={() => void approveActiveVersion()}
2067
+ title="Promote this active version's views to the identity's canonical set"
2068
+ >
2069
+ {promoting ? "Approving…" : "✓ approve this version"}
2070
+ </button>
2071
+ {meshDone && (
2072
+ <a className="vi-btn vi-btn-sm vi-btn-ghost" href={mediaBytesUrl(meshDone)} download>↓ GLB</a>
2073
+ )}
2074
+ {promoteMsg && (
2075
+ <span className="vi-comfy-hint" role="status" style={{ opacity: 0.85 }}>{promoteMsg}</span>
2076
+ )}
2077
+ </div>
2078
+ {/* Sheet/angle-ring per-view picking stays available for granular approval. */}
2079
+ {activeRecon && activeRecon.mode === "sheet" && (activeRecon.views as string[]).length > 0 && (
2080
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.45rem", marginTop: "0.5rem" }}>
2081
+ {(activeRecon.views as string[]).map((relPath, idx) => (
2082
+ <ReconViewTile
2083
+ key={relPath}
2084
+ relPath={relPath}
2085
+ label={VIEW_LABELS[DEFAULT_VIEWS[idx]] ?? `view ${idx + 1}`}
2086
+ canonical={canonicalSet.has(relPath)}
2087
+ selected={selected.has(idx)}
2088
+ busy={recon.running || promoting}
2089
+ onToggle={() => toggleSelected(idx)}
2090
+ onRegenerate={() => regenerateView(idx)}
2091
+ />
2092
+ ))}
2093
+ </div>
2094
+ )}
2095
+ </div>
2096
+ </div>
2097
+
2098
+ {/* ── BOTTOM (wireframe o/p/r): uploaded refs (left, add/remove) · canonical
2099
+ 8 grid with angle labels (right) · the DNA note. ─────────────────────── */}
2100
+ <div className="vi-id-bottom" style={{ display: "grid", gridTemplateColumns: "minmax(0, 440px) minmax(0, 1fr)", gap: "1rem", alignItems: "start" }}>
2101
+ {/* Uploaded references — add/remove, live-saved via update(). */}
2102
+ <div>
2103
+ <p className="vi-comfy-label" style={{ margin: "0 0 0.3rem" }}>
2104
+ Uploaded references ({refs.length})
2105
+ </p>
2106
+ <ReferenceEditor refs={refs} onAdd={addRef} onRemove={removeRef} libraryImages={libraryImages} busy={busy} />
2107
+ <div style={{ display: "flex", gap: "0.4rem", marginTop: "0.4rem", alignItems: "center" }}>
2108
+ {(() => {
2109
+ const refsChanged =
2110
+ refs.length !== profile.reference_images.length ||
2111
+ refs.some((r, i) => r !== profile.reference_images[i]);
2112
+ return (
2113
+ <button
2114
+ type="button"
2115
+ className="vi-btn vi-btn-sm vi-btn-accent"
2116
+ disabled={busy || !refsChanged || refs.length === 0}
2117
+ onClick={() => { onTouch(profile.slug); void save(); }}
2118
+ title={refs.length === 0 ? "An identity keeps ≥1 reference" : "Save the reference list"}
2119
+ >
2120
+ {busy ? "Saving…" : "Save references"}
2121
+ </button>
2122
+ );
2123
+ })()}
2124
+ {msg && <span className="vi-error" role="alert" style={{ fontSize: "0.78rem" }}>{msg}</span>}
2125
+ </div>
2126
+ </div>
2127
+
2128
+ {/* CANONICAL 8 — the identity DNA (constraint #2): drives off TOP-LEVEL
2129
+ profile.canonical only, with the honest angle label (viewLabel reused
2130
+ from GenIdentityBar). NEVER falls back to raw reference_images. */}
2131
+ <div>
2132
+ <p className="vi-comfy-label" style={{ margin: "0 0 0.3rem" }}>
2133
+ Canonical {canonicalPaths.length ? `(${canonicalPaths.length})` : ""}
2134
+ </p>
2135
+ {canonicalPaths.length === 0 ? (
2136
+ <p className="vi-comfy-hint" style={{ opacity: 0.7 }}>
2137
+ No canonical views yet — approve the active version to build the identity's canonical set.
2138
+ </p>
2139
+ ) : (
2140
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(5rem, 1fr))", gap: "0.4rem" }}>
2141
+ {canonicalPaths.map((relPath, idx) => (
2142
+ <figure key={`${relPath}_${idx}`} style={{ margin: 0, display: "flex", flexDirection: "column", gap: "0.15rem" }}>
2143
+ <img
2144
+ src={mediaBytesUrl(relPath)}
2145
+ alt={`${profile.name} canonical ${viewLabel(profile, idx)}`}
2146
+ title={`canonical · ${viewLabel(profile, idx)}`}
2147
+ style={{ width: "100%", aspectRatio: "1", objectFit: "cover", borderRadius: "0.35rem", background: "#000", border: "1px solid var(--vi-accent, var(--vi-border))" }}
2148
+ />
2149
+ <figcaption className="vi-comfy-hint" style={{ fontSize: "0.68rem", opacity: 0.8, textAlign: "center" }}>
2150
+ {viewLabel(profile, idx)}
2151
+ </figcaption>
2152
+ </figure>
2153
+ ))}
2154
+ </div>
2155
+ )}
2156
+ <p className="vi-comfy-hint" style={{ margin: "0.4rem 0 0", opacity: 0.65, fontSize: "0.72rem" }}>
2157
+ Canonical = the identity DNA — sent to generation, never raw uploads.
2158
+ </p>
2159
+ </div>
2160
+ </div>
2161
+
2162
+ {/* Versions list (activate / rename / archive) + settings (when not the
2163
+ settings tab, kept reachable here). */}
2164
+ <VersionsPanel
2165
+ profile={profile}
2166
+ activateVersion={activateVersion}
2167
+ renameVersion={renameVersion}
2168
+ archiveVersion={archiveVersion}
2169
+ />
2170
+ {tab !== "settings" && (
2171
+ <details className="vi-id-settings-disclosure">
2172
+ <summary className="vi-comfy-label" style={{ cursor: "pointer" }}>⚙ Settings & advanced</summary>
2173
+ <SettingsPanel profile={profile} saveSettings={saveSettings} />
2174
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.4rem", marginTop: "0.5rem" }}>
2175
+ <button type="button" className={`vi-btn vi-btn-sm ${sheetOpen && sheetMode === "angle-ring" ? "vi-btn-accent" : "vi-btn-ghost"}`}
2176
+ onClick={() => openPanel("angle-ring")} title="Generate a 36-angle iterative approval ring">✦ Angle ring</button>
2177
+ <button type="button" className={`vi-btn vi-btn-sm ${sheetOpen && sheetMode === "sheet" ? "vi-btn-accent" : "vi-btn-ghost"}`}
2178
+ onClick={() => openPanel("sheet")} title="Legacy: turnaround character sheet">✦ Character sheet</button>
2179
+ <button type="button" className={`vi-btn vi-btn-sm ${sheetOpen && sheetMode === "turntable" ? "vi-btn-accent" : "vi-btn-ghost"}`}
2180
+ onClick={() => openPanel("turntable")} title="Legacy: 360° orbit turntable clip">✦ 360° turntable</button>
2181
+ </div>
2182
+ {sheetOpen && (
2183
+ <section className="vi-comfy-bar" aria-label={`Generate a turnaround for ${profile.name}`}
2184
+ style={{ marginTop: "0.4rem", padding: "0.5rem", borderRadius: "0.4rem", border: "1px solid var(--vi-border)" }}>
2185
+ <label className="vi-comfy-field" style={{ flex: "1 1 100%" }}>
2186
+ <span className="vi-comfy-label">Turnaround prompt (optional)</span>
2187
+ <textarea className="vi-knob-input" rows={2} value={sheetPrompt} placeholder="defaults to this identity's notes"
2188
+ disabled={recon.running} onChange={(e) => setSheetPrompt(e.target.value)} />
2189
+ </label>
2190
+ <label className="vi-comfy-field" style={{ flexBasis: "8rem" }}>
2191
+ <span className="vi-comfy-label">Seed (optional)</span>
2192
+ <input type="number" className="vi-knob-input" value={sheetSeed} placeholder="random"
2193
+ disabled={recon.running} onChange={(e) => setSheetSeed(e.target.value)} />
2194
+ </label>
2195
+ <div className="vi-comfy-bar-actions">
2196
+ <button type="button" className="vi-btn vi-btn-sm vi-btn-accent" disabled={recon.running}
2197
+ onClick={sheetMode === "angle-ring" ? generateAngleRing : sheetMode === "turntable" ? generateTurntable : generateSheet}>
2198
+ {recon.running ? `Generating… (${recon.done}/${recon.total})`
2199
+ : sheetMode === "angle-ring" ? "✦ Generate Angle Ring"
2200
+ : sheetMode === "turntable" ? "✦ Generate 360° turntable" : "✦ Generate turnaround"}
2201
+ </button>
2202
+ </div>
2203
+ {recon.error && <p className="vi-error" role="alert" style={{ flexBasis: "100%", margin: 0 }}>{recon.error}</p>}
2204
+ </section>
2205
+ )}
2206
+ </details>
2207
+ )}
2208
+ </article>
2209
+ );
2210
+ }
2211
+
2212
+ // ── the station — master/detail per the operator's t17 wireframe ─────────────
2213
+ // "basically one 'name' per view rather than all in view" — the tab is a FOCUSED
2214
+ // single-identity detail page: the sidebar (master list + active/session/settings
2215
+ // tab filter) and the header name picker SWITCH which one identity fills the main
2216
+ // area. The old grid-of-cards (every identity rendered at once) is gone; each
2217
+ // ProfileCard body is now the IdentityDetail rendered for exactly the selected
2218
+ // slug. All data flow, the desync-safe active-version logic, generate/poll, and
2219
+ // the promote/approve paths are REUSED verbatim from ProfileCard — this is a
2220
+ // rearrangement, not a rewrite.
2221
+ type IdTab = "active" | "session" | "settings";
2222
+
2223
+ export function IdentitiesStation({ spec }: { spec: StationSpec }) {
2224
+ void spec;
2225
+ const {
2226
+ profiles,
2227
+ loading,
2228
+ error,
2229
+ create,
2230
+ update,
2231
+ remove,
2232
+ reload,
2233
+ saveSettings,
2234
+ activateVersion,
2235
+ renameVersion,
2236
+ archiveVersion,
2237
+ extractFromVideo,
2238
+ } = useIdentityProfiles();
2239
+ const library = useMediaLibrary();
2240
+ const libraryImages = library.filter((it) => it.ref.kind === "image");
2241
+
2242
+ // Sidebar tab (per-tab list filter). "settings" opens the detail on its
2243
+ // settings surface; the list stays visible so you can pick which identity.
2244
+ const [tab, setTab] = useState<IdTab>("active");
2245
+ // The ONE selected identity, keyed by SLUG so it survives the hook's reload
2246
+ // cadence (same discipline as the Serving-table multi-select Set) — the
2247
+ // profiles array is replaced on every reload, but the slug is stable.
2248
+ const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
2249
+ // "+ new identity" opens the create panel inline above the detail.
2250
+ const [creating, setCreating] = useState(false);
2251
+ // "session" = identities TOUCHED this session (selected / created / generated).
2252
+ // There is no server-side last-used timestamp on the profile wire, so this is
2253
+ // a client-side Set of slugs, seeded as identities are interacted with. Kept
2254
+ // in a ref-backed state so it survives reloads like the selection does.
2255
+ const [sessionSlugs, setSessionSlugs] = useState<Set<string>>(() => new Set());
2256
+ const touch = useCallback((slug: string) => {
2257
+ setSessionSlugs((prev) => (prev.has(slug) ? prev : new Set(prev).add(slug)));
2258
+ }, []);
2259
+
2260
+ // Selecting an identity marks it touched-this-session and opens its detail.
2261
+ const select = useCallback(
2262
+ (slug: string) => {
2263
+ setSelectedSlug(slug);
2264
+ setCreating(false);
2265
+ touch(slug);
2266
+ },
2267
+ [touch],
2268
+ );
2269
+
2270
+ // Keep the selection valid across reloads: if the selected slug vanished
2271
+ // (archived elsewhere), fall back to the first identity; if nothing is
2272
+ // selected yet, default to the first once the list arrives.
2273
+ useEffect(() => {
2274
+ if (loading) return;
2275
+ const has = (s: string | null) => s != null && profiles.some((p) => p.slug === s);
2276
+ if (!has(selectedSlug)) {
2277
+ setSelectedSlug(profiles.length ? profiles[0].slug : null);
2278
+ }
2279
+ }, [loading, profiles, selectedSlug]);
2280
+
2281
+ // Per-tab list filter (operator labels kept; mapping noted in the report):
2282
+ // active = identities with an active/approved version (active_version set)
2283
+ // session = identities touched this session (client Set)
2284
+ // settings = ALL identities (the list is the picker for which to configure)
2285
+ const listed = profiles.filter((p) => {
2286
+ if (tab === "active") return p.active_version != null;
2287
+ if (tab === "session") return sessionSlugs.has(p.slug);
2288
+ return true; // settings
2289
+ });
2290
+
2291
+ const selected = profiles.find((p) => p.slug === selectedSlug) ?? null;
2292
+
2293
+ const TABS: { id: IdTab; label: string; title: string }[] = [
2294
+ { id: "active", label: "active", title: "Identities with an approved/active version" },
2295
+ { id: "session", label: "session", title: "Identities you've touched this session" },
2296
+ { id: "settings", label: "settings", title: "Identity settings — pick one to configure" },
2297
+ ];
2298
+
2299
+ return (
2300
+ <section className="station-wide vi-identities vi-identities-md" aria-label="Identities workspace"
2301
+ style={{ display: "grid", gridTemplateColumns: "300px minmax(0, 1fr)", gap: "1rem", alignItems: "start" }}>
2302
+ {/* ── SIDEBAR: tab filter + master list (wireframe b/c/d/f/e) ────────── */}
2303
+ <aside className="vi-id-sidebar" aria-label="Identity list"
2304
+ style={{ border: "1px solid var(--vi-border)", borderRadius: "0.5rem", padding: "0.5rem", position: "sticky", top: "0.5rem" }}>
2305
+ <div role="tablist" aria-label="Identity list filter"
2306
+ style={{ display: "flex", gap: "0.25rem", marginBottom: "0.5rem" }}>
2307
+ {TABS.map((t) => (
2308
+ <button
2309
+ key={t.id}
2310
+ type="button"
2311
+ role="tab"
2312
+ aria-selected={tab === t.id}
2313
+ title={t.title}
2314
+ className={`vi-btn vi-btn-sm ${tab === t.id ? "vi-btn-accent" : "vi-btn-ghost"}`}
2315
+ style={{ flex: 1 }}
2316
+ onClick={() => setTab(t.id)}
2317
+ >
2318
+ {t.label}
2319
+ </button>
2320
+ ))}
2321
+ </div>
2322
+ {loading ? (
2323
+ <p className="vi-comfy-hint">Loading…</p>
2324
+ ) : error ? (
2325
+ <p className="vi-error" role="alert">{error}</p>
2326
+ ) : listed.length === 0 ? (
2327
+ <p className="vi-comfy-hint" style={{ padding: "0.4rem" }}>
2328
+ {tab === "session"
2329
+ ? "No identities touched yet this session."
2330
+ : tab === "active"
2331
+ ? "No identity has an active version yet — generate one."
2332
+ : "No saved identities yet — “+ new” to create one."}
2333
+ </p>
2334
+ ) : (
2335
+ <ul className="vi-id-list" style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: "0.25rem", maxHeight: "640px", overflowY: "auto" }}>
2336
+ {listed.map((p) => {
2337
+ const isSel = p.slug === selectedSlug;
2338
+ const thumb = p.canonical[0] ?? p.reference_images[0];
2339
+ return (
2340
+ <li key={p.slug}>
2341
+ <button
2342
+ type="button"
2343
+ className="vi-id-list-item"
2344
+ aria-current={isSel}
2345
+ onClick={() => select(p.slug)}
2346
+ style={{
2347
+ display: "flex", alignItems: "center", gap: "0.5rem", width: "100%",
2348
+ textAlign: "left", padding: "0.35rem 0.45rem", borderRadius: "0.4rem",
2349
+ border: `1px solid ${isSel ? "var(--vi-accent, #6aa8ff)" : "var(--vi-border)"}`,
2350
+ background: isSel ? "rgba(106,168,255,0.10)" : "transparent",
2351
+ cursor: "pointer", color: "inherit",
2352
+ }}
2353
+ >
2354
+ {thumb ? (
2355
+ <img src={mediaBytesUrl(thumb)} alt="" aria-hidden="true"
2356
+ style={{ width: "2.2rem", height: "2.2rem", objectFit: "cover", borderRadius: "0.3rem", background: "#000", flex: "0 0 auto" }} />
2357
+ ) : (
2358
+ <span aria-hidden="true" style={{ width: "2.2rem", height: "2.2rem", borderRadius: "0.3rem", background: "var(--vi-border)", flex: "0 0 auto" }} />
2359
+ )}
2360
+ <span style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
2361
+ <span style={{ fontSize: "0.85rem", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.name}</span>
2362
+ <span className="vi-comfy-hint" style={{ fontSize: "0.68rem", opacity: 0.7 }}>
2363
+ {p.active_version != null ? "● active" : "○ no active version"}
2364
+ {p.canonical.length ? ` · ${p.canonical.length} canonical` : ""}
2365
+ </span>
2366
+ </span>
2367
+ </button>
2368
+ </li>
2369
+ );
2370
+ })}
2371
+ </ul>
2372
+ )}
2373
+ </aside>
2374
+
2375
+ {/* ── MAIN: header pickers + single-identity detail ──────────────────── */}
2376
+ <div className="vi-id-main" style={{ display: "flex", flexDirection: "column", gap: "0.75rem", minWidth: 0 }}>
2377
+ {/* Header row: NAME picker + “+ new” (wireframe i/h). The version picker
2378
+ (k/j) lives inside the detail, which owns version state + generate. */}
2379
+ <div className="vi-id-header" style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", alignItems: "center" }}>
2380
+ <label className="vi-comfy-label" style={{ opacity: 0.75 }}>Identity</label>
2381
+ <select
2382
+ className="vi-knob-input"
2383
+ style={{ flex: "1 1 20rem", maxWidth: "27rem" }}
2384
+ value={selectedSlug ?? ""}
2385
+ disabled={loading || profiles.length === 0}
2386
+ onChange={(e) => e.target.value && select(e.target.value)}
2387
+ >
2388
+ {profiles.length === 0 && <option value="">— no identities yet —</option>}
2389
+ {profiles.map((p) => (
2390
+ <option key={p.slug} value={p.slug}>{p.name}</option>
2391
+ ))}
2392
+ </select>
2393
+ <button
2394
+ type="button"
2395
+ className={`vi-btn vi-btn-sm ${creating ? "vi-btn-accent" : "vi-btn-ghost"}`}
2396
+ aria-expanded={creating}
2397
+ onClick={() => setCreating((v) => !v)}
2398
+ title="Create a new identity"
2399
+ >
2400
+ + new
2401
+ </button>
2402
+ </div>
2403
+
2404
+ {/* “+ new identity” — the create panel + video-extract path, inline. */}
2405
+ {creating && (
2406
+ <div style={{ border: "1px solid var(--vi-border)", borderRadius: "0.5rem", padding: "0.5rem" }}>
2407
+ <CreatePanel
2408
+ create={async (name, refs, notes) => {
2409
+ const res = await create(name, refs, notes);
2410
+ if (res.ok && res.profile) {
2411
+ touch(res.profile.slug);
2412
+ setSelectedSlug(res.profile.slug);
2413
+ setCreating(false);
2414
+ }
2415
+ return res;
2416
+ }}
2417
+ libraryImages={libraryImages}
2418
+ />
2419
+ <ExtractFromVideoPanel profiles={profiles} extractFromVideo={extractFromVideo} reload={reload} />
2420
+ </div>
2421
+ )}
2422
+
2423
+ {selected ? (
2424
+ <IdentityDetail
2425
+ key={selected.slug}
2426
+ profile={selected}
2427
+ tab={tab}
2428
+ update={update}
2429
+ remove={(slug) => {
2430
+ // After a delete the selection must move off the gone slug.
2431
+ const res = remove(slug);
2432
+ setSelectedSlug((cur) => (cur === slug ? null : cur));
2433
+ return res;
2434
+ }}
2435
+ reload={reload}
2436
+ libraryImages={libraryImages}
2437
+ saveSettings={saveSettings}
2438
+ activateVersion={activateVersion}
2439
+ renameVersion={renameVersion}
2440
+ archiveVersion={archiveVersion}
2441
+ onTouch={touch}
2442
+ />
2443
+ ) : (
2444
+ !loading && (
2445
+ <p className="vi-comfy-hint" style={{ padding: "1rem" }}>
2446
+ {profiles.length === 0
2447
+ ? "No identities yet — click “+ new” to create your first."
2448
+ : "Select an identity from the list to view it."}
2449
+ </p>
2450
+ )
2451
+ )}
2452
+ </div>
2453
+ </section>
2454
+ );
2455
+ }