@hugpy/video-intelligence-ui 0.1.1 → 0.3.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 (102) hide show
  1. package/package.json +1 -3
  2. package/src/config.ts +36 -0
  3. package/src/demo/assets/generate/cinema-cavern/movie.mp4 +0 -0
  4. package/src/demo/assets/generate/cinema-cavern/seg_00.mp4 +0 -0
  5. package/src/demo/assets/generate/clips/cat-lounge.mp4 +0 -0
  6. package/src/demo/assets/generate/clips/drone-city.mp4 +0 -0
  7. package/src/demo/assets/generate/clips/paper-sails.mp4 +0 -0
  8. package/src/demo/assets/generate/movie-galaxy/movie.mp4 +0 -0
  9. package/src/demo/assets/generate/movie-galaxy/seg_00.mp4 +0 -0
  10. package/src/demo/assets/generate/movie-galaxy/seg_01.mp4 +0 -0
  11. package/src/demo/assets/generate/movie-galaxy/seg_02.mp4 +0 -0
  12. package/src/demo/assets/generate/movie-galaxy/seg_03.mp4 +0 -0
  13. package/src/demo/assets/generate/movie-medow/movie.mp4 +0 -0
  14. package/src/demo/assets/generate/movie-medow/seg_00.mp4 +0 -0
  15. package/src/demo/assets/generate/movie-medow/seg_01.mp4 +0 -0
  16. package/src/demo/assets/generate/scene-desert/frame_00000.png +0 -0
  17. package/src/demo/assets/generate/scene-desert/frame_00001.png +0 -0
  18. package/src/demo/assets/generate/scene-desert/frame_00002.png +0 -0
  19. package/src/demo/assets/generate/scene-desert/frame_00003.png +0 -0
  20. package/src/demo/assets/generate/scene-desert/frame_00004.png +0 -0
  21. package/src/demo/assets/generate/scene-desert/frame_00005.png +0 -0
  22. package/src/demo/assets/generate/scene-desert/scene.mp4 +0 -0
  23. package/src/demo/assets/generate/scene-vintage/frame_00000.png +0 -0
  24. package/src/demo/assets/generate/scene-vintage/frame_00001.png +0 -0
  25. package/src/demo/assets/generate/scene-vintage/frame_00002.png +0 -0
  26. package/src/demo/assets/generate/scene-vintage/frame_00003.png +0 -0
  27. package/src/demo/assets/generate/scene-vintage/frame_00004.png +0 -0
  28. package/src/demo/assets/generate/scene-vintage/frame_00005.png +0 -0
  29. package/src/demo/assets/generate/scene-vintage/scene.mp4 +0 -0
  30. package/src/demo/assets/generate2/s0.png +0 -0
  31. package/src/demo/assets/generate2/s1.png +0 -0
  32. package/src/demo/assets/generate2/s10.png +0 -0
  33. package/src/demo/assets/generate2/s11.png +0 -0
  34. package/src/demo/assets/generate2/s12.png +0 -0
  35. package/src/demo/assets/generate2/s13.png +0 -0
  36. package/src/demo/assets/generate2/s14.png +0 -0
  37. package/src/demo/assets/generate2/s15.png +0 -0
  38. package/src/demo/assets/generate2/s16.png +0 -0
  39. package/src/demo/assets/generate2/s17.png +0 -0
  40. package/src/demo/assets/generate2/s18.png +0 -0
  41. package/src/demo/assets/generate2/s19.png +0 -0
  42. package/src/demo/assets/generate2/s2.png +0 -0
  43. package/src/demo/assets/generate2/s20.png +0 -0
  44. package/src/demo/assets/generate2/s21.mp4 +0 -0
  45. package/src/demo/assets/generate2/s22.mp4 +0 -0
  46. package/src/demo/assets/generate2/s23.mp4 +0 -0
  47. package/src/demo/assets/generate2/s24.mp4 +0 -0
  48. package/src/demo/assets/generate2/s25.mp4 +0 -0
  49. package/src/demo/assets/generate2/s26.mp4 +0 -0
  50. package/src/demo/assets/generate2/s27.mp4 +0 -0
  51. package/src/demo/assets/generate2/s28.mp4 +0 -0
  52. package/src/demo/assets/generate2/s29.mp4 +0 -0
  53. package/src/demo/assets/generate2/s3.png +0 -0
  54. package/src/demo/assets/generate2/s30.mp4 +0 -0
  55. package/src/demo/assets/generate2/s4.png +0 -0
  56. package/src/demo/assets/generate2/s5.png +0 -0
  57. package/src/demo/assets/generate2/s6.png +0 -0
  58. package/src/demo/assets/generate2/s7.png +0 -0
  59. package/src/demo/assets/generate2/s8.png +0 -0
  60. package/src/demo/assets/generate2/s9.png +0 -0
  61. package/src/demo/demoFetch.ts +126 -3
  62. package/src/demo/fixtures.ts +72 -4
  63. package/src/demo/promptAssist.ts +25 -0
  64. package/src/demo/sampleData.ts +960 -0
  65. package/src/demo/sampleSets.ts +172 -0
  66. package/src/stations/ActiveProcessesStation.tsx +18 -3
  67. package/src/stations/GenerateStation.tsx +901 -366
  68. package/src/stations/GoalComposer.tsx +9 -1
  69. package/src/stations/ScriptFirstStation.tsx +1081 -0
  70. package/src/stations/ToksPanel.tsx +188 -0
  71. package/src/stations/WorkbenchDrawer.tsx +4 -3
  72. package/src/stations/WorkbenchSidebar.tsx +6 -3
  73. package/src/stations/oracle/DagPanel.tsx +391 -0
  74. package/src/stations/oracle/OracleStation.tsx +304 -0
  75. package/src/stations/oracle/SpatialTab.tsx +185 -0
  76. package/src/stations/oracle/StewardPanel.tsx +165 -0
  77. package/src/stations/oracle/oracle.css +467 -0
  78. package/src/stations/oracle/oracleShared.tsx +160 -0
  79. package/src/stations/oracle/oracleTypes.ts +304 -0
  80. package/src/stations/oracle/useOracle.ts +560 -0
  81. package/src/stations/registry.ts +37 -0
  82. package/src/stations/scriptFirst.css +350 -0
  83. package/src/stations/studio/IdentitiesStation.tsx +219 -292
  84. package/src/stations/studio/IdentityCreatePanel.tsx +390 -0
  85. package/src/stations/studio/StudioGenerateTab.tsx +10 -17
  86. package/src/stations/studio/StudioMovieComposer.tsx +296 -59
  87. package/src/stations/studio/StudioPlane.tsx +35 -3
  88. package/src/stations/studio/StudioViewer.tsx +116 -14
  89. package/src/stations/studio/coordinationBadge.tsx +206 -0
  90. package/src/stations/studio/identityUpload.ts +66 -0
  91. package/src/stations/studio/promptCard.tsx +362 -59
  92. package/src/stations/studio/useIdentityCreateJob.ts +184 -0
  93. package/src/stations/studio/useIdentityProfiles.ts +66 -0
  94. package/src/stations/useScriptFirstRun.ts +596 -0
  95. package/src/style/app.css +157 -0
  96. package/src/video/LengthRow.tsx +189 -0
  97. package/src/video/coordinationReport.ts +266 -0
  98. package/src/video/jobClock.ts +104 -0
  99. package/src/video/spreadAssist.ts +26 -0
  100. package/src/video/useMediaJobs.ts +26 -0
  101. package/src/video/useOperatorHistory.ts +5 -0
  102. package/src/video/useToks.ts +193 -0
@@ -2,11 +2,17 @@
2
2
  // (studio stage (a)'s durable library item), separate from the compact "👤 From
3
3
  // profile" picker the Studio Generate/Movie surfaces embed. Those surfaces only
4
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.
5
+ // built and maintained.
6
+ //
7
+ // k94 — ONE PATH (the clownworld MO): the top of the tab is IdentityCreatePanel —
8
+ // name + [+ video] [+ images] + ONE "Create identity" button + a progress line.
9
+ // A video (or a few photos) in; a bindable character (reference views + 3D GLB)
10
+ // out. Everything else this tab used to expose as separate steps (the manual
11
+ // create panel, char360 knobs, pose / vision model / frames / fps / size,
12
+ // turnaround prompt, seed, regeneration prompt, the per-step generate / approve /
13
+ // new-version buttons) lives under ONE collapsed `> advanced` expander, still
14
+ // functional, default-hidden. The profile list + viewer (canonical views,
15
+ // turntable, GLB) stay, with a ✓ when an identity is ready to bind.
10
16
  //
11
17
  // Backed entirely by useIdentityProfiles (list/create/update/remove) — this file
12
18
  // adds no new wire contract, just the surface. The reference-image upload path
@@ -28,13 +34,13 @@ import * as THREE from "three";
28
34
  import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
29
35
  import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
30
36
  import { request, okValue, errorOf, describeAppError } from "../../transport/client";
31
- import { hugpyConfig, mediaBytesUrl, identityGenerateUrl, identityMeshStatusUrl, videoJobUrl } from "../../config";
32
- import { addToLibrary, useMediaLibrary } from "../../video/mediaLibrary";
37
+ import { mediaBytesUrl, identityGenerateUrl, identityMeshStatusUrl, videoJobUrl } from "../../config";
38
+ import { useMediaLibrary } from "../../video/mediaLibrary";
33
39
  import type { LibraryItem } from "../../video/mediaLibrary";
34
- import { mediaRefSchema, uploadResultSchema } from "../../video/contract";
35
40
  import type { MediaRef, IdentityReconstruction, IdentityAngleView } from "../../video/contract";
36
- import { getSessionId } from "../../session";
37
41
  import { LibraryImageGrid } from "./studioShared";
42
+ import { uploadAndIngestImage, uploadAndIngestVideo } from "./identityUpload";
43
+ import { IdentityCreatePanel, AdvancedExpander } from "./IdentityCreatePanel";
38
44
  import { useIdentityProfiles } from "./useIdentityProfiles";
39
45
  import type {
40
46
  IdentityProfile,
@@ -61,71 +67,22 @@ const MAX_SOURCE_IMAGES = 12;
61
67
 
62
68
  type UploadPhase = "idle" | "uploading" | "ingesting";
63
69
 
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" },
70
+ // uploadAndIngestImage / uploadAndIngestVideo moved to ./identityUpload (k94) so the
71
+ // one-path create panel shares them without a circular import — same behavior.
72
+
73
+ /** "Ready to bind" (k94): views (a canonical set, or any reconstruction with views)
74
+ * AND a finished mesh (some reconstruction's mesh.status === "done" + glb_path).
75
+ * Reads the same snake_case wire truth the detail's viewers gate on. */
76
+ export function identityReadiness(p: IdentityProfile): { views: boolean; mesh: boolean; ready: boolean } {
77
+ const recons = p.reconstructions ?? [];
78
+ const views =
79
+ (p.canonical ?? []).length > 0 ||
80
+ recons.some((r) => Array.isArray(r.views) && (r.views as unknown[]).length > 0);
81
+ const mesh = recons.some((r) => {
82
+ const m = (r as { mesh?: { status?: string; glb_path?: string | null } | null }).mesh;
83
+ return !!m && m.status === "done" && !!m.glb_path;
111
84
  });
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 };
85
+ return { views, mesh, ready: views && mesh };
129
86
  }
130
87
 
131
88
  function fmtCreated(t: number | null | undefined): { short: string; full: string } {
@@ -388,20 +345,18 @@ function AngleRingEditor({ reconstruction, api }: { reconstruction: IdentityReco
388
345
  value={regeneratePrompt}
389
346
  onChange={(e) => setRegeneratePrompt(e.target.value)}
390
347
  />
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
348
  </label>
395
349
 
396
350
  <div style={{ display: "flex", flexWrap: "wrap", gap: "0.45rem", maxHeight: "28rem", overflowY: "auto", paddingRight: "0.5rem" }}>
397
351
  {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
- />
352
+ <div key={view.view_id} style={{ display: "contents" }}>
353
+ <AngleRingTile
354
+ view={view}
355
+ reconId={reconstruction.recon_id} // <--- snake_case
356
+ api={api}
357
+ promptOverride={regeneratePrompt}
358
+ />
359
+ </div>
405
360
  ))}
406
361
  </div>
407
362
  </div>
@@ -481,7 +436,7 @@ function ReferenceEditor({
481
436
  if (files && files.length > 0) {
482
437
  void (async () => {
483
438
  // Convert FileList to Array and enforce space limits
484
- const fileArray = Array.from(files);
439
+ const fileArray = Array.from(files as ArrayLike<File>);
485
440
  const remainingSlots = MAX_SOURCE_IMAGES - refs.length;
486
441
 
487
442
  if (fileArray.length > remainingSlots) {
@@ -669,11 +624,6 @@ export function ExtractFromVideoPanel({
669
624
  <section className="vi-comfy-bar" aria-label="Extract character views from a video" style={{ marginTop: "0.8rem" }}>
670
625
  <div style={{ flexBasis: "100%" }}>
671
626
  <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
627
  </div>
678
628
 
679
629
  <div style={{ display: "flex", flexWrap: "wrap", gap: "0.6rem", alignItems: "center" }}>
@@ -1100,9 +1050,6 @@ function SettingsPanel({
1100
1050
  <option value="none">None</option>
1101
1051
  <option value="t-pose">T-pose</option>
1102
1052
  </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
1053
  </label>
1107
1054
 
1108
1055
  <label className="vi-comfy-field" style={{ flexBasis: "20rem" }}>
@@ -1143,10 +1090,6 @@ function SettingsPanel({
1143
1090
  ↻
1144
1091
  </button>
1145
1092
  </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
1093
  {visionError && <span className="vi-error">{visionError}</span>}
1151
1094
  </label>
1152
1095
 
@@ -1169,10 +1112,6 @@ function SettingsPanel({
1169
1112
  placeholder="e.g. no object on back, no logos, clean bare back"
1170
1113
  onChange={(e) => setDraft((d) => ({ ...d, cleanup_prompt: e.target.value }))}
1171
1114
  />
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
1115
  </label>
1177
1116
 
1178
1117
  <label className="vi-comfy-field" style={{ flexBasis: "20rem" }}>
@@ -1186,10 +1125,6 @@ function SettingsPanel({
1186
1125
  placeholder="e.g. backpack, symbols, logo"
1187
1126
  onChange={(e) => setDraft((d) => ({ ...d, negative_prompt: e.target.value }))}
1188
1127
  />
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
1128
  </label>
1194
1129
 
1195
1130
  <div style={{ display: "flex", flexWrap: "wrap", gap: "0.6rem" }}>
@@ -1506,8 +1441,8 @@ function VersionsPanel({
1506
1441
  const isBase = v.kind === "clay" && v.name === "base";
1507
1442
  const isActive = profile.active_version != null && profile.active_version === v.version_id;
1508
1443
  return (
1444
+ <div key={v.version_id} style={{ display: "contents" }}>
1509
1445
  <VersionRow
1510
- key={v.version_id}
1511
1446
  version={v}
1512
1447
  isActive={isActive}
1513
1448
  isBase={isBase}
@@ -1515,13 +1450,10 @@ function VersionsPanel({
1515
1450
  onRename={(name) => renameVersion(profile.slug, v.version_id, name)}
1516
1451
  onArchive={() => archiveVersion(profile.slug, v.version_id)}
1517
1452
  />
1453
+ </div>
1518
1454
  );
1519
1455
  })}
1520
1456
  </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
1457
  </div>
1526
1458
  );
1527
1459
  }
@@ -1927,16 +1859,133 @@ function IdentityDetail({
1927
1859
  }
1928
1860
  }
1929
1861
 
1862
+ const readiness = identityReadiness(profile);
1863
+
1864
+ // Everything that used to be a separate STEP on this page (k94): rebuild (new
1865
+ // version) / ✓ approve / sheet picking / angle-ring approval / settings /
1866
+ // versions / the legacy 2D render actions. Still functional; ONE expander.
1867
+ const advancedBody = (
1868
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.6rem", flex: "1 1 100%", minWidth: 0 }}>
1869
+ {poseNotice && (
1870
+ <p className="vi-comfy-hint" role="status" style={{ margin: 0, opacity: 0.85 }}>{poseNotice}</p>
1871
+ )}
1872
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.4rem", alignItems: "center" }}>
1873
+ <button
1874
+ type="button"
1875
+ className="vi-btn vi-btn-sm vi-btn-accent"
1876
+ disabled={genBusy || busy}
1877
+ onClick={() => void generateFullIdentity()}
1878
+ title="Rebuild: a 3D mesh, a 360° turntable, auto-promoted canonical references — lands as a new version"
1879
+ >
1880
+ {genBusy ? "Generating…" : "⟳ rebuild 3D (new version)"}
1881
+ </button>
1882
+ <button
1883
+ type="button"
1884
+ className="vi-btn vi-btn-sm vi-btn-ghost"
1885
+ disabled={!canApproveActive}
1886
+ onClick={() => void approveActiveVersion()}
1887
+ title="Promote this active version's views to the identity's canonical set"
1888
+ >
1889
+ {promoting ? "Approving…" : "✓ approve this version"}
1890
+ </button>
1891
+ {genStatus && (
1892
+ <span className="vi-comfy-hint" role="status" style={{ opacity: 0.85 }}>{genStatus}</span>
1893
+ )}
1894
+ {promoteMsg && (
1895
+ <span className="vi-comfy-hint" role="status" style={{ opacity: 0.85 }}>{promoteMsg}</span>
1896
+ )}
1897
+ </div>
1898
+
1899
+ {/* Sheet per-view picking stays available for granular approval. */}
1900
+ {activeRecon && activeRecon.mode === "sheet" && (activeRecon.views as string[]).length > 0 && (
1901
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.45rem" }}>
1902
+ {(activeRecon.views as string[]).map((relPath, idx) => (
1903
+ <div key={relPath} style={{ display: "contents" }}>
1904
+ <ReconViewTile
1905
+ relPath={relPath}
1906
+ label={VIEW_LABELS[DEFAULT_VIEWS[idx]] ?? `view ${idx + 1}`}
1907
+ canonical={canonicalSet.has(relPath)}
1908
+ selected={selected.has(idx)}
1909
+ busy={recon.running || promoting}
1910
+ onToggle={() => toggleSelected(idx)}
1911
+ onRegenerate={() => regenerateView(idx)}
1912
+ />
1913
+ </div>
1914
+ ))}
1915
+ </div>
1916
+ )}
1917
+ {activeRecon && activeRecon.mode === "angle-ring" && (
1918
+ <AngleRingEditor reconstruction={activeRecon as any} api={recon} />
1919
+ )}
1920
+
1921
+ <SettingsPanel profile={profile} saveSettings={saveSettings} />
1922
+ <VersionsPanel
1923
+ profile={profile}
1924
+ activateVersion={activateVersion}
1925
+ renameVersion={renameVersion}
1926
+ archiveVersion={archiveVersion}
1927
+ />
1928
+
1929
+ {/* Legacy 2D render actions (angle ring / character sheet / orbit-clip 360). */}
1930
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.4rem" }}>
1931
+ <button type="button" className={`vi-btn vi-btn-sm ${sheetOpen && sheetMode === "angle-ring" ? "vi-btn-accent" : "vi-btn-ghost"}`}
1932
+ onClick={() => openPanel("angle-ring")} title="Generate a 36-angle iterative approval ring">✦ Angle ring</button>
1933
+ <button type="button" className={`vi-btn vi-btn-sm ${sheetOpen && sheetMode === "sheet" ? "vi-btn-accent" : "vi-btn-ghost"}`}
1934
+ onClick={() => openPanel("sheet")} title="Legacy: turnaround character sheet">✦ Character sheet</button>
1935
+ <button type="button" className={`vi-btn vi-btn-sm ${sheetOpen && sheetMode === "turntable" ? "vi-btn-accent" : "vi-btn-ghost"}`}
1936
+ onClick={() => openPanel("turntable")} title="Legacy: 360° orbit turntable clip">✦ 360° turntable</button>
1937
+ </div>
1938
+ {sheetOpen && (
1939
+ <section className="vi-comfy-bar" aria-label={`Generate a turnaround for ${profile.name}`}
1940
+ style={{ padding: "0.5rem", borderRadius: "0.4rem", border: "1px solid var(--vi-border)" }}>
1941
+ <label className="vi-comfy-field" style={{ flex: "1 1 100%" }}>
1942
+ <span className="vi-comfy-label">Turnaround prompt (optional)</span>
1943
+ <textarea className="vi-knob-input" rows={2} value={sheetPrompt} placeholder="defaults to this identity's notes"
1944
+ disabled={recon.running} onChange={(e) => setSheetPrompt(e.target.value)} />
1945
+ </label>
1946
+ <label className="vi-comfy-field" style={{ flexBasis: "8rem" }}>
1947
+ <span className="vi-comfy-label">Seed (optional)</span>
1948
+ <input type="number" className="vi-knob-input" value={sheetSeed} placeholder="random"
1949
+ disabled={recon.running} onChange={(e) => setSheetSeed(e.target.value)} />
1950
+ </label>
1951
+ <div className="vi-comfy-bar-actions">
1952
+ <button type="button" className="vi-btn vi-btn-sm vi-btn-accent" disabled={recon.running}
1953
+ onClick={sheetMode === "angle-ring" ? generateAngleRing : sheetMode === "turntable" ? generateTurntable : generateSheet}>
1954
+ {recon.running ? `Generating… (${recon.done}/${recon.total})`
1955
+ : sheetMode === "angle-ring" ? "✦ Generate Angle Ring"
1956
+ : sheetMode === "turntable" ? "✦ Generate 360° turntable" : "✦ Generate turnaround"}
1957
+ </button>
1958
+ </div>
1959
+ {recon.error && <p className="vi-error" role="alert" style={{ flexBasis: "100%", margin: 0 }}>{recon.error}</p>}
1960
+ </section>
1961
+ )}
1962
+ </div>
1963
+ );
1964
+
1930
1965
  return (
1931
1966
  <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. ───────────────────────────────── */}
1967
+ {/* ── HEADER: name · created · ✓ ready to bind · version picker · edit/delete ── */}
1935
1968
  <div className="vi-id-detail-head" style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", alignItems: "center" }}>
1936
1969
  <h3 style={{ margin: 0, fontSize: "1.05rem", flex: "0 0 auto" }}>{profile.name}</h3>
1937
1970
  <span className="vi-comfy-hint" title={createdFull} style={{ opacity: 0.7, fontSize: "0.75rem" }}>
1938
1971
  {createdShort ? `created ${createdShort}` : ""}
1939
1972
  </span>
1973
+ <span
1974
+ className="vi-comfy-hint"
1975
+ role="status"
1976
+ title={
1977
+ readiness.ready
1978
+ ? "Reference views + a 3D model exist — bind it from “+ identity” in Scene/Movie."
1979
+ : `Not ready yet — ${!readiness.views ? "no reference views" : ""}${!readiness.views && !readiness.mesh ? ", " : ""}${!readiness.mesh ? "no 3D model" : ""}.`
1980
+ }
1981
+ style={{
1982
+ padding: "0.05rem 0.5rem", borderRadius: "0.4rem", fontSize: "0.75rem",
1983
+ border: `1px solid ${readiness.ready ? "var(--vi-accent, #6aa8ff)" : "var(--vi-border)"}`,
1984
+ opacity: readiness.ready ? 1 : 0.75,
1985
+ }}
1986
+ >
1987
+ {readiness.ready ? "✓ ready to bind" : genBusy ? "building…" : "○ not ready"}
1988
+ </span>
1940
1989
  <span style={{ flex: 1 }} />
1941
1990
  <label className="vi-comfy-label" style={{ opacity: 0.75 }}>Version</label>
1942
1991
  <select
@@ -1945,7 +1994,7 @@ function IdentityDetail({
1945
1994
  value={profile.active_version ?? ""}
1946
1995
  disabled={(profile.versions ?? []).length === 0}
1947
1996
  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"}
1997
+ title={(profile.versions ?? []).length === 0 ? "No versions yet" : "Switch the active version"}
1949
1998
  >
1950
1999
  {(profile.versions ?? []).length === 0 && <option value="">— no versions yet —</option>}
1951
2000
  {(profile.versions ?? []).map((v) => {
@@ -1957,15 +2006,6 @@ function IdentityDetail({
1957
2006
  );
1958
2007
  })}
1959
2008
  </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
2009
  <button type="button" className="vi-btn vi-btn-sm vi-btn-ghost" disabled={busy || genBusy} onClick={editing ? cancelEdit : startEdit}>
1970
2010
  {editing ? "Close edit" : "Edit"}
1971
2011
  </button>
@@ -1974,16 +2014,13 @@ function IdentityDetail({
1974
2014
  </button>
1975
2015
  </div>
1976
2016
 
1977
- {/* Inline edit (rename display-only + notes + refs) — opened from the header. */}
2017
+ {/* Inline edit (rename display-only + notes) — opened from the header. */}
1978
2018
  {editing && (
1979
2019
  <div style={{ border: "1px solid var(--vi-border)", borderRadius: "0.5rem", padding: "0.6rem", display: "flex", flexDirection: "column", gap: "0.4rem" }}>
1980
2020
  <label className="vi-comfy-field">
1981
2021
  <span className="vi-comfy-label">Name</span>
1982
2022
  <input type="text" className="vi-knob-input" value={name} disabled={busy} onChange={(e) => setName(e.target.value)} />
1983
2023
  </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
2024
  <label className="vi-comfy-field">
1988
2025
  <span className="vi-comfy-label">Notes</span>
1989
2026
  <textarea className="vi-knob-input" rows={2} value={notes} disabled={busy} onChange={(e) => setNotes(e.target.value)} />
@@ -1998,19 +2035,9 @@ function IdentityDetail({
1998
2035
  </div>
1999
2036
  )}
2000
2037
 
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”. ─────────────────────────────────────────── */}
2038
+ {/* ── VIEWER: turntable (left) · 3D model (right). Wire-truth off the ACTIVE
2039
+ version's reconstruction (mesh.status === "done" + mesh.glb_path). ── */}
2012
2040
  <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
2041
  <div>
2015
2042
  <p className="vi-comfy-label" style={{ margin: "0 0 0.3rem" }}>Turntable {turntableRecon ? "(drag to orbit)" : ""}</p>
2016
2043
  <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" }}>
@@ -2020,88 +2047,37 @@ function IdentityDetail({
2020
2047
  degreesPerFrame={(turntableRecon as any).degrees_per_frame}
2021
2048
  frameCount={(turntableRecon as any).frame_count}
2022
2049
  />
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
2050
  ) : (
2028
2051
  <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.
2052
+ {genBusy ? (genStatus || "Building…") : "No turntable for the active version."}
2030
2053
  </span>
2031
2054
  )}
2032
2055
  </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
2056
  </div>
2048
-
2049
- {/* Approved 3D id (active version) + ✓ approve this version */}
2050
2057
  <div>
2051
- <p className="vi-comfy-label" style={{ margin: "0 0 0.3rem" }}>Approved 3D identity (active version)</p>
2058
+ <p className="vi-comfy-label" style={{ margin: "0 0 0.3rem" }}>3D model (active version)</p>
2052
2059
  <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
2060
  {meshDone ? (
2054
2061
  <MeshViewer url={mediaBytesUrl(meshDone)} height={320} />
2055
2062
  ) : (
2056
2063
  <span className="vi-comfy-hint" style={{ opacity: 0.7, padding: "1rem", textAlign: "center" }}>
2057
- No approved 3D mesh for the active version yet.
2064
+ {genBusy ? (genStatus || "Building…") : "No 3D model for the active version."}
2058
2065
  </span>
2059
2066
  )}
2060
2067
  </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 && (
2068
+ {meshDone && (
2069
+ <div style={{ display: "flex", gap: "0.4rem", marginTop: "0.4rem", alignItems: "center" }}>
2072
2070
  <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
2071
  </div>
2094
2072
  )}
2095
2073
  </div>
2096
2074
  </div>
2097
2075
 
2098
- {/* ── BOTTOM (wireframe o/p/r): uploaded refs (left, add/remove) · canonical
2099
- 8 grid with angle labels (right) · the DNA note. ─────────────────────── */}
2076
+ {/* ── references (left, add/remove) · canonical views (right) ── */}
2100
2077
  <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
2078
  <div>
2103
2079
  <p className="vi-comfy-label" style={{ margin: "0 0 0.3rem" }}>
2104
- Uploaded references ({refs.length})
2080
+ References ({refs.length})
2105
2081
  </p>
2106
2082
  <ReferenceEditor refs={refs} onAdd={addRef} onRemove={removeRef} libraryImages={libraryImages} busy={busy} />
2107
2083
  <div style={{ display: "flex", gap: "0.4rem", marginTop: "0.4rem", alignItems: "center" }}>
@@ -2125,17 +2101,14 @@ function IdentityDetail({
2125
2101
  </div>
2126
2102
  </div>
2127
2103
 
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. */}
2104
+ {/* CANONICAL — the identity DNA: TOP-LEVEL profile.canonical only, with the
2105
+ honest angle label (viewLabel reused from GenIdentityBar). */}
2131
2106
  <div>
2132
2107
  <p className="vi-comfy-label" style={{ margin: "0 0 0.3rem" }}>
2133
- Canonical {canonicalPaths.length ? `(${canonicalPaths.length})` : ""}
2108
+ Canonical views {canonicalPaths.length ? `(${canonicalPaths.length})` : ""}
2134
2109
  </p>
2135
2110
  {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>
2111
+ <p className="vi-comfy-hint" style={{ opacity: 0.7 }}>No canonical views yet.</p>
2139
2112
  ) : (
2140
2113
  <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(5rem, 1fr))", gap: "0.4rem" }}>
2141
2114
  {canonicalPaths.map((relPath, idx) => (
@@ -2153,58 +2126,12 @@ function IdentityDetail({
2153
2126
  ))}
2154
2127
  </div>
2155
2128
  )}
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
2129
  </div>
2160
2130
  </div>
2161
2131
 
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
- )}
2132
+ {/* ONE expander for everything else (k94). The sidebar "settings" tab leads
2133
+ with it open; every other tab keeps it collapsed. */}
2134
+ <AdvancedExpander open={tab === "settings"}>{advancedBody}</AdvancedExpander>
2208
2135
  </article>
2209
2136
  );
2210
2137
  }
@@ -2235,6 +2162,8 @@ export function IdentitiesStation({ spec }: { spec: StationSpec }) {
2235
2162
  renameVersion,
2236
2163
  archiveVersion,
2237
2164
  extractFromVideo,
2165
+ createFromVideo,
2166
+ createFromImages,
2238
2167
  } = useIdentityProfiles();
2239
2168
  const library = useMediaLibrary();
2240
2169
  const libraryImages = library.filter((it) => it.ref.kind === "image");
@@ -2246,8 +2175,6 @@ export function IdentitiesStation({ spec }: { spec: StationSpec }) {
2246
2175
  // cadence (same discipline as the Serving-table multi-select Set) — the
2247
2176
  // profiles array is replaced on every reload, but the slug is stable.
2248
2177
  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
2178
  // "session" = identities TOUCHED this session (selected / created / generated).
2252
2179
  // There is no server-side last-used timestamp on the profile wire, so this is
2253
2180
  // a client-side Set of slugs, seeded as identities are interacted with. Kept
@@ -2261,7 +2188,6 @@ export function IdentitiesStation({ spec }: { spec: StationSpec }) {
2261
2188
  const select = useCallback(
2262
2189
  (slug: string) => {
2263
2190
  setSelectedSlug(slug);
2264
- setCreating(false);
2265
2191
  touch(slug);
2266
2192
  },
2267
2193
  [touch],
@@ -2329,7 +2255,7 @@ export function IdentitiesStation({ spec }: { spec: StationSpec }) {
2329
2255
  ? "No identities touched yet this session."
2330
2256
  : tab === "active"
2331
2257
  ? "No identity has an active version yet — generate one."
2332
- : "No saved identities yet — “+ new” to create one."}
2258
+ : "No saved identities yet — create one above."}
2333
2259
  </p>
2334
2260
  ) : (
2335
2261
  <ul className="vi-id-list" style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: "0.25rem", maxHeight: "640px", overflowY: "auto" }}>
@@ -2360,8 +2286,8 @@ export function IdentitiesStation({ spec }: { spec: StationSpec }) {
2360
2286
  <span style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
2361
2287
  <span style={{ fontSize: "0.85rem", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.name}</span>
2362
2288
  <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` : ""}
2289
+ {identityReadiness(p).ready ? "✓ ready to bind" : p.active_version != null ? "● active" : "○ not ready"}
2290
+ {p.canonical.length ? ` · ${p.canonical.length} views` : ""}
2365
2291
  </span>
2366
2292
  </span>
2367
2293
  </button>
@@ -2372,10 +2298,38 @@ export function IdentitiesStation({ spec }: { spec: StationSpec }) {
2372
2298
  )}
2373
2299
  </aside>
2374
2300
 
2375
- {/* ── MAIN: header pickers + single-identity detail ──────────────────── */}
2301
+ {/* ── MAIN: the ONE PATH create panel (k94) · identity picker · detail ── */}
2376
2302
  <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. */}
2303
+ <IdentityCreatePanel
2304
+ createFromVideo={createFromVideo}
2305
+ createFromImages={createFromImages}
2306
+ library={library}
2307
+ reload={reload}
2308
+ onCreated={(slugs) => {
2309
+ for (const s of slugs) touch(s);
2310
+ if (slugs[0]) setSelectedSlug(slugs[0]);
2311
+ }}
2312
+ advancedOpen={tab === "settings"}
2313
+ advanced={
2314
+ <div style={{ display: "flex", flexDirection: "column", gap: "0.4rem", flex: "1 1 100%", minWidth: 0 }}>
2315
+ <span className="vi-comfy-label" style={{ opacity: 0.75 }}>Manual create (name + notes + reference images, no 3D build)</span>
2316
+ <CreatePanel
2317
+ create={async (name, refs, notes) => {
2318
+ const res = await create(name, refs, notes);
2319
+ if (res.ok && res.profile) {
2320
+ touch(res.profile.slug);
2321
+ setSelectedSlug(res.profile.slug);
2322
+ }
2323
+ return res;
2324
+ }}
2325
+ libraryImages={libraryImages}
2326
+ />
2327
+ <ExtractFromVideoPanel profiles={profiles} extractFromVideo={extractFromVideo} reload={reload} />
2328
+ </div>
2329
+ }
2330
+ />
2331
+
2332
+ {/* Identity picker — which saved identity fills the detail below. */}
2379
2333
  <div className="vi-id-header" style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", alignItems: "center" }}>
2380
2334
  <label className="vi-comfy-label" style={{ opacity: 0.75 }}>Identity</label>
2381
2335
  <select
@@ -2387,42 +2341,14 @@ export function IdentitiesStation({ spec }: { spec: StationSpec }) {
2387
2341
  >
2388
2342
  {profiles.length === 0 && <option value="">— no identities yet —</option>}
2389
2343
  {profiles.map((p) => (
2390
- <option key={p.slug} value={p.slug}>{p.name}</option>
2344
+ <option key={p.slug} value={p.slug}>{identityReadiness(p).ready ? "✓ " : ""}{p.name}</option>
2391
2345
  ))}
2392
2346
  </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
2347
  </div>
2403
2348
 
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
2349
  {selected ? (
2350
+ <div key={selected.slug} style={{ display: "contents" }}>
2424
2351
  <IdentityDetail
2425
- key={selected.slug}
2426
2352
  profile={selected}
2427
2353
  tab={tab}
2428
2354
  update={update}
@@ -2440,11 +2366,12 @@ export function IdentitiesStation({ spec }: { spec: StationSpec }) {
2440
2366
  archiveVersion={archiveVersion}
2441
2367
  onTouch={touch}
2442
2368
  />
2369
+ </div>
2443
2370
  ) : (
2444
2371
  !loading && (
2445
2372
  <p className="vi-comfy-hint" style={{ padding: "1rem" }}>
2446
2373
  {profiles.length === 0
2447
- ? "No identities yet — click “+ new” to create your first."
2374
+ ? "No identities yet — create your first above."
2448
2375
  : "Select an identity from the list to view it."}
2449
2376
  </p>
2450
2377
  )