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