@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.
- package/package.json +1 -3
- package/src/config.ts +36 -0
- package/src/demo/assets/generate/cinema-cavern/movie.mp4 +0 -0
- package/src/demo/assets/generate/cinema-cavern/seg_00.mp4 +0 -0
- package/src/demo/assets/generate/clips/cat-lounge.mp4 +0 -0
- package/src/demo/assets/generate/clips/drone-city.mp4 +0 -0
- package/src/demo/assets/generate/clips/paper-sails.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/movie.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_00.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_01.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_02.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_03.mp4 +0 -0
- package/src/demo/assets/generate/movie-medow/movie.mp4 +0 -0
- package/src/demo/assets/generate/movie-medow/seg_00.mp4 +0 -0
- package/src/demo/assets/generate/movie-medow/seg_01.mp4 +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00000.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00001.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00002.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00003.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00004.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00005.png +0 -0
- package/src/demo/assets/generate/scene-desert/scene.mp4 +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00000.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00001.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00002.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00003.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00004.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00005.png +0 -0
- package/src/demo/assets/generate/scene-vintage/scene.mp4 +0 -0
- package/src/demo/assets/generate2/s0.png +0 -0
- package/src/demo/assets/generate2/s1.png +0 -0
- package/src/demo/assets/generate2/s10.png +0 -0
- package/src/demo/assets/generate2/s11.png +0 -0
- package/src/demo/assets/generate2/s12.png +0 -0
- package/src/demo/assets/generate2/s13.png +0 -0
- package/src/demo/assets/generate2/s14.png +0 -0
- package/src/demo/assets/generate2/s15.png +0 -0
- package/src/demo/assets/generate2/s16.png +0 -0
- package/src/demo/assets/generate2/s17.png +0 -0
- package/src/demo/assets/generate2/s18.png +0 -0
- package/src/demo/assets/generate2/s19.png +0 -0
- package/src/demo/assets/generate2/s2.png +0 -0
- package/src/demo/assets/generate2/s20.png +0 -0
- package/src/demo/assets/generate2/s21.mp4 +0 -0
- package/src/demo/assets/generate2/s22.mp4 +0 -0
- package/src/demo/assets/generate2/s23.mp4 +0 -0
- package/src/demo/assets/generate2/s24.mp4 +0 -0
- package/src/demo/assets/generate2/s25.mp4 +0 -0
- package/src/demo/assets/generate2/s26.mp4 +0 -0
- package/src/demo/assets/generate2/s27.mp4 +0 -0
- package/src/demo/assets/generate2/s28.mp4 +0 -0
- package/src/demo/assets/generate2/s29.mp4 +0 -0
- package/src/demo/assets/generate2/s3.png +0 -0
- package/src/demo/assets/generate2/s30.mp4 +0 -0
- package/src/demo/assets/generate2/s4.png +0 -0
- package/src/demo/assets/generate2/s5.png +0 -0
- package/src/demo/assets/generate2/s6.png +0 -0
- package/src/demo/assets/generate2/s7.png +0 -0
- package/src/demo/assets/generate2/s8.png +0 -0
- package/src/demo/assets/generate2/s9.png +0 -0
- package/src/demo/demoFetch.ts +126 -3
- package/src/demo/fixtures.ts +72 -4
- package/src/demo/promptAssist.ts +25 -0
- package/src/demo/sampleData.ts +960 -0
- package/src/demo/sampleSets.ts +172 -0
- package/src/stations/ActiveProcessesStation.tsx +18 -3
- package/src/stations/GenerateStation.tsx +901 -366
- package/src/stations/GoalComposer.tsx +9 -1
- package/src/stations/ScriptFirstStation.tsx +1081 -0
- package/src/stations/ToksPanel.tsx +188 -0
- package/src/stations/WorkbenchDrawer.tsx +4 -3
- package/src/stations/WorkbenchSidebar.tsx +6 -3
- package/src/stations/oracle/DagPanel.tsx +391 -0
- package/src/stations/oracle/OracleStation.tsx +304 -0
- package/src/stations/oracle/SpatialTab.tsx +185 -0
- package/src/stations/oracle/StewardPanel.tsx +165 -0
- package/src/stations/oracle/oracle.css +467 -0
- package/src/stations/oracle/oracleShared.tsx +160 -0
- package/src/stations/oracle/oracleTypes.ts +304 -0
- package/src/stations/oracle/useOracle.ts +560 -0
- package/src/stations/registry.ts +37 -0
- package/src/stations/scriptFirst.css +350 -0
- package/src/stations/studio/IdentitiesStation.tsx +219 -292
- package/src/stations/studio/IdentityCreatePanel.tsx +390 -0
- package/src/stations/studio/StudioGenerateTab.tsx +10 -17
- package/src/stations/studio/StudioMovieComposer.tsx +296 -59
- package/src/stations/studio/StudioPlane.tsx +35 -3
- package/src/stations/studio/StudioViewer.tsx +116 -14
- package/src/stations/studio/coordinationBadge.tsx +206 -0
- package/src/stations/studio/identityUpload.ts +66 -0
- package/src/stations/studio/promptCard.tsx +362 -59
- package/src/stations/studio/useIdentityCreateJob.ts +184 -0
- package/src/stations/studio/useIdentityProfiles.ts +66 -0
- package/src/stations/useScriptFirstRun.ts +596 -0
- package/src/style/app.css +157 -0
- package/src/video/LengthRow.tsx +189 -0
- package/src/video/coordinationReport.ts +266 -0
- package/src/video/jobClock.ts +104 -0
- package/src/video/spreadAssist.ts +26 -0
- package/src/video/useMediaJobs.ts +26 -0
- package/src/video/useOperatorHistory.ts +5 -0
- 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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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 {
|
|
32
|
-
import {
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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'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
|
|
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
|
|
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
|
|
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
|
-
{
|
|
2002
|
-
|
|
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
|
|
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" }}>
|
|
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
|
|
2064
|
+
{genBusy ? (genStatus || "Building…") : "No 3D model for the active version."}
|
|
2058
2065
|
</span>
|
|
2059
2066
|
)}
|
|
2060
2067
|
</div>
|
|
2061
|
-
|
|
2062
|
-
<
|
|
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
|
-
{/* ──
|
|
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
|
-
|
|
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
|
|
2129
|
-
|
|
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
|
-
{/*
|
|
2163
|
-
|
|
2164
|
-
<
|
|
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 —
|
|
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" : "○
|
|
2364
|
-
{p.canonical.length ? ` · ${p.canonical.length}
|
|
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:
|
|
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
|
-
|
|
2378
|
-
|
|
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 —
|
|
2374
|
+
? "No identities yet — create your first above."
|
|
2448
2375
|
: "Select an identity from the list to view it."}
|
|
2449
2376
|
</p>
|
|
2450
2377
|
)
|