@hugpy/video-intelligence-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/LICENSE +41 -0
  2. package/assets/sampels/images/bycycle/bd8603bad35d4f179f4d1134d8faf2ee.mp4 +0 -0
  3. package/assets/sampels/images/bycycle/frame_00000.png +0 -0
  4. package/assets/sampels/images/bycycle/frame_00001.png +0 -0
  5. package/assets/sampels/images/bycycle/frame_00002.png +0 -0
  6. package/assets/sampels/images/bycycle/frame_00003.png +0 -0
  7. package/assets/sampels/images/bycycle/frame_00004.png +0 -0
  8. package/assets/sampels/images/bycycle/frame_00005.png +0 -0
  9. package/assets/sampels/images/forrest/ff23090611b844a6b85c224f388bd542.mp4 +0 -0
  10. package/assets/sampels/images/forrest/frame_00000.png +0 -0
  11. package/assets/sampels/images/forrest/frame_00001.png +0 -0
  12. package/assets/sampels/images/forrest/frame_00002.png +0 -0
  13. package/assets/sampels/images/forrest/frame_00003.png +0 -0
  14. package/assets/sampels/images/forrest/frame_00004.png +0 -0
  15. package/assets/sampels/images/forrest/frame_00005.png +0 -0
  16. package/assets/sampels/images/shenzo/e1a4fcd623084ab4a986d11c8cd00db8.mp4 +0 -0
  17. package/assets/sampels/images/shenzo/frame_00000.png +0 -0
  18. package/assets/sampels/images/shenzo/frame_00001.png +0 -0
  19. package/assets/sampels/images/shenzo/frame_00002.png +0 -0
  20. package/assets/sampels/images/shenzo/frame_00003.png +0 -0
  21. package/assets/sampels/images/shenzo/frame_00004.png +0 -0
  22. package/assets/sampels/images/shenzo/frame_00005.png +0 -0
  23. package/assets/sampels/scenes/man.mp4 +0 -0
  24. package/assets/sampels/scenes/woman.mp4 +0 -0
  25. package/assets/sampels/studio_movies/caverns/_assembly/contrib_00.mp4 +0 -0
  26. package/assets/sampels/studio_movies/caverns/movie.json +67 -0
  27. package/assets/sampels/studio_movies/caverns/movie.mp4 +0 -0
  28. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/clip.mp4 +0 -0
  29. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/manifest.json +87 -0
  30. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/provenance.json +7 -0
  31. package/assets/sampels/studio_movies/caverns/segment_01/branch.png +0 -0
  32. package/assets/sampels/studio_movies/keeper-matrix/_assembly/concat_list.txt +2 -0
  33. package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_00.mp4 +0 -0
  34. package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_01.mp4 +0 -0
  35. package/assets/sampels/studio_movies/keeper-matrix/movie.json +81 -0
  36. package/assets/sampels/studio_movies/keeper-matrix/movie.mp4 +0 -0
  37. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/clip.mp4 +0 -0
  38. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/manifest.json +87 -0
  39. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/provenance.json +7 -0
  40. package/assets/sampels/studio_movies/keeper-matrix/segment_01/branch.png +0 -0
  41. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/clip.mp4 +0 -0
  42. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/manifest.json +87 -0
  43. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/provenance.json +7 -0
  44. package/assets/sampels/videos/a_red_paper_boat_drifting_on_a_calm_pond__gentle_ripples__mo_a62d6c28.mp4 +0 -0
  45. package/assets/sampels/videos/aurora.mp4 +0 -0
  46. package/assets/sampels/videos/dark_night.mp4 +0 -0
  47. package/assets/sampels/videos/fire_and_soul.mp4 +0 -0
  48. package/assets/sampels/videos/harsh_seas.mp4 +0 -0
  49. package/entry.tsx +74 -0
  50. package/index.html +53 -0
  51. package/package.json +42 -0
  52. package/src/assets/hugpy-mark.png +0 -0
  53. package/src/config.ts +676 -0
  54. package/src/demo/DemoBanner.tsx +54 -0
  55. package/src/demo/assets/demo-audio.mp3 +0 -0
  56. package/src/demo/assets/demo-clip.mp4 +0 -0
  57. package/src/demo/assets/demo-crop.mp4 +0 -0
  58. package/src/demo/assets/demo-poster.jpg +0 -0
  59. package/src/demo/assets/frame-1.jpg +0 -0
  60. package/src/demo/assets/frame-2.jpg +0 -0
  61. package/src/demo/assets/frame-3.jpg +0 -0
  62. package/src/demo/assets/frame-4.jpg +0 -0
  63. package/src/demo/assets/frame-5.jpg +0 -0
  64. package/src/demo/assets/frame-6.jpg +0 -0
  65. package/src/demo/assets/gen-1.jpg +0 -0
  66. package/src/demo/assets/gen-2.jpg +0 -0
  67. package/src/demo/assets.d.ts +14 -0
  68. package/src/demo/demo.css +64 -0
  69. package/src/demo/demoFetch.ts +305 -0
  70. package/src/demo/fixtures.ts +814 -0
  71. package/src/demo/index.ts +43 -0
  72. package/src/demo/mode.ts +81 -0
  73. package/src/demo/promptAssist.ts +181 -0
  74. package/src/demo/seed.ts +97 -0
  75. package/src/nav/BrandMark.css +66 -0
  76. package/src/nav/BrandMark.tsx +40 -0
  77. package/src/nav/Navbar.css +13 -0
  78. package/src/nav/Navbar.tsx +68 -0
  79. package/src/regions/SpatialRegionEditor.tsx +375 -0
  80. package/src/regions/TemporalRegionEditor.tsx +485 -0
  81. package/src/regions/types.ts +85 -0
  82. package/src/session.ts +85 -0
  83. package/src/share.ts +77 -0
  84. package/src/stations/ActivePanel.tsx +55 -0
  85. package/src/stations/ActiveProcessesStation.tsx +272 -0
  86. package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +228 -0
  87. package/src/stations/AddFileBar.tsx +59 -0
  88. package/src/stations/AudioCropStation.tsx +157 -0
  89. package/src/stations/ComfyPanel.tsx +210 -0
  90. package/src/stations/ComfyStation.tsx +196 -0
  91. package/src/stations/CondensedKnobStrip.tsx +245 -0
  92. package/src/stations/ConsolePanel.tsx +82 -0
  93. package/src/stations/FrameExtractStation.tsx +148 -0
  94. package/src/stations/GenIdentityBar.tsx +412 -0
  95. package/src/stations/GenerateStation.tsx +5603 -0
  96. package/src/stations/GenerateStation.tsx.bak-tester-removal +5170 -0
  97. package/src/stations/GoalComposer.tsx +139 -0
  98. package/src/stations/ImageCropStation.tsx +153 -0
  99. package/src/stations/KnobInfo.tsx +64 -0
  100. package/src/stations/PlaceholderStation.tsx +26 -0
  101. package/src/stations/SectionTabs.tsx +276 -0
  102. package/src/stations/SharePanel.tsx +298 -0
  103. package/src/stations/SidebarRegistry.tsx +105 -0
  104. package/src/stations/StationShell.tsx +96 -0
  105. package/src/stations/StudioClipsStation.tsx +28 -0
  106. package/src/stations/WorkbenchDrawer.tsx +66 -0
  107. package/src/stations/WorkbenchSidebar.tsx +1224 -0
  108. package/src/stations/WorkbenchStation.tsx +91 -0
  109. package/src/stations/comfyEmbed.ts +79 -0
  110. package/src/stations/drawerShell.tsx +419 -0
  111. package/src/stations/ops/AudioCropCore.tsx +218 -0
  112. package/src/stations/ops/CharacterGroupsPanel.tsx +716 -0
  113. package/src/stations/ops/FrameExtractCore.tsx +695 -0
  114. package/src/stations/ops/ImageCropCore.tsx +164 -0
  115. package/src/stations/ops/VideoCropCore.tsx +193 -0
  116. package/src/stations/ops/useVideoCropJobs.ts +224 -0
  117. package/src/stations/pollCaps.ts +87 -0
  118. package/src/stations/registry.ts +191 -0
  119. package/src/stations/studio/AngleRingEditor.tsx +159 -0
  120. package/src/stations/studio/IdentitiesStation.tsx +2455 -0
  121. package/src/stations/studio/IdentityProfileControls.tsx +262 -0
  122. package/src/stations/studio/StudioActiveProcesses.tsx +224 -0
  123. package/src/stations/studio/StudioAssistLogPanel.tsx +220 -0
  124. package/src/stations/studio/StudioGenerateMode.tsx +29 -0
  125. package/src/stations/studio/StudioGenerateTab.tsx +2688 -0
  126. package/src/stations/studio/StudioItemCard.tsx +334 -0
  127. package/src/stations/studio/StudioLibraryTab.tsx +506 -0
  128. package/src/stations/studio/StudioMovieComposer.tsx +2875 -0
  129. package/src/stations/studio/StudioMovieComposer.tsx.bak-sessions-20260812 +2729 -0
  130. package/src/stations/studio/StudioMovieSessions.tsx +553 -0
  131. package/src/stations/studio/StudioMovieSessions.tsx.bak-sessions-20260812 +417 -0
  132. package/src/stations/studio/StudioPlane.tsx +302 -0
  133. package/src/stations/studio/StudioSplicedRow.tsx +476 -0
  134. package/src/stations/studio/StudioViewer.tsx +339 -0
  135. package/src/stations/studio/TurntableViewer.tsx +220 -0
  136. package/src/stations/studio/movieTimeline.ts +198 -0
  137. package/src/stations/studio/promptCard.tsx +498 -0
  138. package/src/stations/studio/studioItem.ts +87 -0
  139. package/src/stations/studio/studioShared.tsx +821 -0
  140. package/src/stations/studio/useIdentityProfiles.ts +575 -0
  141. package/src/stations/studio/useReconstruction.ts +283 -0
  142. package/src/stations/types.ts +43 -0
  143. package/src/stations/useAudioCropJobs.ts +202 -0
  144. package/src/stations/useAudioExtractJob.ts +144 -0
  145. package/src/stations/useCropJobs.ts +206 -0
  146. package/src/stations/useFrameJobs.ts +140 -0
  147. package/src/stations/useGenerateJob.ts +151 -0
  148. package/src/stations/useGenerateMovieJob.ts +177 -0
  149. package/src/stations/useGenerateRowJobs.ts +184 -0
  150. package/src/stations/useGenerateSceneJob.ts +156 -0
  151. package/src/stations/useGenerateSceneRowJobs.ts +282 -0
  152. package/src/style/app.css +4974 -0
  153. package/src/transport/client.ts +262 -0
  154. package/src/transport/telemetry.ts +97 -0
  155. package/src/video/DropReceptacle.tsx +122 -0
  156. package/src/video/PromptAssistButtons.tsx +118 -0
  157. package/src/video/composerBridge.ts +66 -0
  158. package/src/video/contract.ts +599 -0
  159. package/src/video/jobTracker.ts +702 -0
  160. package/src/video/mediaLibrary.ts +328 -0
  161. package/src/video/renderCompat.ts +512 -0
  162. package/src/video/sessionForm.ts +189 -0
  163. package/src/video/spreadAssist.ts +392 -0
  164. package/src/video/studioBridge.ts +82 -0
  165. package/src/video/useMediaJobs.ts +279 -0
  166. package/src/video/useModels.ts +413 -0
  167. package/src/video/useMoviePresets.ts +97 -0
  168. package/src/video/useMovieSessions.ts +292 -0
  169. package/src/video/usePresets.ts +167 -0
  170. package/src/video/useProjects.ts +87 -0
  171. package/src/video/usePromptAssist.ts +394 -0
  172. package/src/video/useRenderPresets.ts +221 -0
  173. package/src/video/useStudioAssistLog.ts +266 -0
  174. package/src/video/useStudioPresets.ts +128 -0
  175. package/src/video/useWorkerCalls.ts +165 -0
  176. package/tsconfig.json +17 -0
  177. package/vite.config.ts +33 -0
@@ -0,0 +1,716 @@
1
+ // CharacterGroupsPanel — the char360 REVIEW + CURATE surface (dev/CHARACTER-GROUPS-PLAN.md
2
+ // slice S2). A sibling of ExtractFromVideoPanel inside the Frames station's
3
+ // "Character tools" group: where ExtractFromVideoPanel commits char→profile mapping
4
+ // SERVER-SIDE at submit time (target:"create"/"<slug>"), THIS panel runs the S1
5
+ // "review" extraction (target:"review", enqueue-only) which returns the per-character
6
+ // grouped manifest WITHOUT writing anything, then lets the user curate the groups
7
+ // (remove / move / merge / rename / select-to-proceed) and only THEN commits the
8
+ // chosen groups to identity profiles via the S3 from-groups route.
9
+ //
10
+ // WHY A SIBLING, NOT A SECOND useIdentityProfiles() MOUNT — the same reason
11
+ // GenIdentityBar is a pure prop-driven sibling: useIdentityProfiles() has no
12
+ // cross-instance cache, so mounting it twice fires duplicate GET
13
+ // /video/identity-profiles and holds two independently-stale lists. This panel is
14
+ // PURE — it takes the ONE station hook's `extractFromVideo` + the loaded `source`
15
+ // as props (FrameExtractCore owns the single hook instance).
16
+ //
17
+ // CURATION STATE IS CLIENT-HELD — keyed off the S1 manifest, mutated locally
18
+ // (remove/move/merge). Nothing is written until "Create identities from selected
19
+ // groups" POSTs the from-groups body. Reversible by design (re-run review to reset).
20
+ import { useEffect, useRef, useState } from "react";
21
+ import {
22
+ mediaBytesUrl,
23
+ videoJobUrl,
24
+ hugpyConfig,
25
+ identityGenerateUrl,
26
+ identityMeshStatusUrl,
27
+ } from "../../config";
28
+ import { request, okValue, errorOf, describeAppError } from "../../transport/client";
29
+ import type { MediaRef } from "../../video/contract";
30
+ import type { IdentityProfilesState } from "../studio/useIdentityProfiles";
31
+ import { MeshViewer } from "../studio/IdentitiesStation";
32
+
33
+ // ── S1 review manifest shapes (tolerant reads — the poll result is cast, exactly
34
+ // as ExtractFromVideoPanel casts its job result; strictNullChecks is OFF in this
35
+ // scoped tsconfig so these are flat, nullable-friendly shapes). ─────────────────
36
+ interface ReviewView {
37
+ url: string; // media HANDLE (jailed absolute path) — render via mediaBytesUrl(url)
38
+ yaw: number | null;
39
+ bin: number | null;
40
+ score: number | null;
41
+ }
42
+ interface ReviewGroup {
43
+ char: string;
44
+ face_centroid: number[] | null;
45
+ views: ReviewView[];
46
+ }
47
+ interface ReviewManifest {
48
+ n_characters: number;
49
+ groups: ReviewGroup[];
50
+ }
51
+
52
+ // ── client-held (editable) curation model ───────────────────────────────────────
53
+ interface GroupState {
54
+ /** Stable local id (survives rename; merges keep the target id). */
55
+ id: string;
56
+ /** Editable display name — defaults to the manifest `char` id. */
57
+ name: string;
58
+ /** The original char360 char id (immutable provenance, shown as a subtitle). */
59
+ char: string;
60
+ views: ReviewView[];
61
+ /** Whether this group advances to the commit step. */
62
+ proceed: boolean;
63
+ /** Per-group commit outcome (set after a from-groups POST), or null. */
64
+ slug: string | null;
65
+ error: string | null;
66
+ }
67
+
68
+ // Response of the S3 from-groups route: { results: [{ name, ok, slug?, error? }] }.
69
+ interface CommitResult {
70
+ name?: string;
71
+ ok?: boolean;
72
+ slug?: string;
73
+ error?: string;
74
+ }
75
+
76
+ // ── S4 per-group 3D job state (dev/CHARACTER-GROUPS-PLAN.md) — keyed by the
77
+ // committed profile `slug`. Owned INTERNALLY by this panel so "Generate 3D"
78
+ // works whether or not a caller passes `onGenerate3D` (that prop stays a pure
79
+ // fire-and-forget notify hook, called once when a job starts, never driving
80
+ // state here). Mirrors IdentitiesStation's `generateFullIdentity` one-click
81
+ // flow verbatim: POST identityGenerateUrl(slug) with a bare {} body (the
82
+ // happy path — mesh + 360 turntable + auto-promote), then poll
83
+ // identityMeshStatusUrl(slug, reconId) every ~5s (bounded ~20min) for the
84
+ // SAME status vocabulary ("none"|"queued"|"running"|"done"|"error"|
85
+ // "cancelled") and the SAME terminal field, `glb_path` (snake_case) —
86
+ // wrapped with mediaBytesUrl for MeshViewer, exactly as Studio does.
87
+ type Mesh3DPhase = "idle" | "running" | "done" | "error";
88
+ interface Mesh3DState {
89
+ phase: Mesh3DPhase;
90
+ status: string | null; // last raw poll status / stage text for display
91
+ glbUrl: string | null;
92
+ error: string | null;
93
+ }
94
+ const IDLE_3D: Mesh3DState = { phase: "idle", status: null, glbUrl: null, error: null };
95
+
96
+ /** Composite selection key — a view lives in exactly one group at a time, but a
97
+ * handle could (in principle) repeat across groups in the raw manifest, so the
98
+ * group id is part of the key. U+241F is a control-picture separator that can't
99
+ * appear in a group id or a path. */
100
+ function selKey(groupId: string, url: string): string {
101
+ return `${groupId}␟${url}`;
102
+ }
103
+
104
+ export interface CharacterGroupsPanelProps {
105
+ /** The loaded source video (kind==="video") — review runs against THIS ref. */
106
+ source: MediaRef | null;
107
+ /** The ONE station hook's enqueue verb (shared — never a second hook mount). */
108
+ extractFromVideo: IdentityProfilesState["extractFromVideo"];
109
+ /** S4 (dev/CHARACTER-GROUPS-PLAN.md): the panel now owns the actual mesh-gen
110
+ * flow internally (POST identityGenerateUrl(slug) → poll
111
+ * identityMeshStatusUrl → MeshViewer), so this button works with or without
112
+ * a caller-supplied hook. This prop is now an OPTIONAL fire-and-forget
113
+ * notify — called once when a group's "Generate 3D" job starts, for a
114
+ * caller that wants to observe it (e.g. cross-station touch tracking). It
115
+ * never drives this panel's own state. */
116
+ onGenerate3D?: (slug: string) => void;
117
+ }
118
+
119
+ export function CharacterGroupsPanel({
120
+ source,
121
+ extractFromVideo,
122
+ onGenerate3D,
123
+ }: CharacterGroupsPanelProps) {
124
+ const [busy, setBusy] = useState(false);
125
+ const [status, setStatus] = useState<string | null>(null);
126
+ const [error, setError] = useState<string | null>(null);
127
+ const [groups, setGroups] = useState<GroupState[]>([]);
128
+ const [reviewed, setReviewed] = useState(false);
129
+ // Multi-select ACROSS groups — composite selKey(groupId, url) entries.
130
+ const [selected, setSelected] = useState<Set<string>>(new Set());
131
+ const [committing, setCommitting] = useState(false);
132
+ const [commitError, setCommitError] = useState<string | null>(null);
133
+ // S4 — per-slug 3D job state + its own active/timer refs (one poll loop per
134
+ // slug can run concurrently, mirrors the group-level state being a map too).
135
+ const [mesh3D, setMesh3D] = useState<Record<string, Mesh3DState>>({});
136
+ const meshActive = useRef<Record<string, boolean>>({});
137
+ const meshTimer = useRef<Record<string, number>>({});
138
+
139
+ const active = useRef(false);
140
+ const timer = useRef<number | null>(null);
141
+ const idSeq = useRef(0);
142
+
143
+ useEffect(() => {
144
+ return () => {
145
+ active.current = false;
146
+ if (timer.current != null) window.clearTimeout(timer.current);
147
+ for (const slug of Object.keys(meshActive.current)) meshActive.current[slug] = false;
148
+ for (const slug of Object.keys(meshTimer.current)) window.clearTimeout(meshTimer.current[slug]);
149
+ };
150
+ }, []);
151
+
152
+ const sourceIsVideo = source != null && source.kind === "video";
153
+ const canReview = sourceIsVideo && !busy;
154
+
155
+ function nextId(): string {
156
+ idSeq.current += 1;
157
+ return `cg${idSeq.current}`;
158
+ }
159
+
160
+ // Build the editable model from the S1 manifest. Fresh ids each review so a
161
+ // re-run cleanly replaces the prior curation (reversible reset).
162
+ function loadManifest(manifest: ReviewManifest) {
163
+ const built: GroupState[] = (manifest.groups || []).map((g) => ({
164
+ id: nextId(),
165
+ name: g.char,
166
+ char: g.char,
167
+ views: (g.views || []).filter((v) => v && v.url),
168
+ proceed: false,
169
+ slug: null,
170
+ error: null,
171
+ }));
172
+ setGroups(built);
173
+ setSelected(new Set());
174
+ setReviewed(true);
175
+ }
176
+
177
+ async function runReview() {
178
+ if (!source || busy) return;
179
+ setBusy(true);
180
+ setError(null);
181
+ setCommitError(null);
182
+ setStatus("Reviewing… (queued)");
183
+ active.current = true;
184
+
185
+ // target:"review" — the S1 enqueue-only, non-committing branch.
186
+ const enq = await extractFromVideo(source, "review");
187
+ if (!active.current) return;
188
+ if (!enq.ok || !enq.jobId) {
189
+ active.current = false;
190
+ setBusy(false);
191
+ setStatus(null);
192
+ setError(enq.message || "Could not start the character review.");
193
+ return;
194
+ }
195
+
196
+ const jobId = enq.jobId;
197
+ const deadline = Date.now() + 20 * 60 * 1000; // ~20 min bound, mirrors ExtractFromVideoPanel
198
+ const pollOnce = async () => {
199
+ if (!active.current) return;
200
+ if (Date.now() > deadline) {
201
+ active.current = false;
202
+ setBusy(false);
203
+ setStatus(null);
204
+ setError("Timed out waiting for the character review.");
205
+ return;
206
+ }
207
+ const res = await request<unknown>(videoJobUrl(jobId), {
208
+ meta: { specKey: "studio", operation: "identity.groups.review.status" },
209
+ });
210
+ if (!active.current) return;
211
+ if (res.ok) {
212
+ const job = okValue(res) as {
213
+ progress?: number | null;
214
+ result?: {
215
+ ok?: boolean;
216
+ groups?: ReviewManifest;
217
+ error?: { code?: string; message?: string; retryable?: boolean };
218
+ } | null;
219
+ stage?: string | null;
220
+ message?: string | null;
221
+ };
222
+ const result = job.result;
223
+ if (result && typeof result.ok === "boolean") {
224
+ active.current = false;
225
+ setBusy(false);
226
+ if (result.ok) {
227
+ setStatus("done");
228
+ if (result.groups && Array.isArray(result.groups.groups)) {
229
+ loadManifest(result.groups);
230
+ } else {
231
+ setError("Review finished but returned no character groups.");
232
+ }
233
+ } else {
234
+ setStatus(null);
235
+ setError((result.error && result.error.message) || "Character review failed.");
236
+ }
237
+ return;
238
+ }
239
+ const pct = typeof job.progress === "number" ? ` (${Math.round(job.progress * 100)}%)` : "";
240
+ const stage = job.stage || job.message;
241
+ setStatus(`Reviewing…${pct}${stage ? ` — ${stage}` : ""}`);
242
+ }
243
+ // queued/running/transient-poll-failure → keep polling.
244
+ timer.current = window.setTimeout(() => void pollOnce(), 5000);
245
+ };
246
+ timer.current = window.setTimeout(() => void pollOnce(), 5000);
247
+ }
248
+
249
+ // ── selection helpers ─────────────────────────────────────────────────────
250
+ function toggleView(groupId: string, url: string) {
251
+ const key = selKey(groupId, url);
252
+ setSelected((prev) => {
253
+ const next = new Set(prev);
254
+ if (next.has(key)) next.delete(key);
255
+ else next.add(key);
256
+ return next;
257
+ });
258
+ }
259
+ function clearSelection() {
260
+ setSelected(new Set());
261
+ }
262
+ const selectedCount = selected.size;
263
+
264
+ // ── curation ops (all client-held) ────────────────────────────────────────
265
+ // Remove every selected view from its group.
266
+ function removeSelected() {
267
+ if (selectedCount === 0) return;
268
+ setGroups((prev) =>
269
+ prev.map((g) => ({
270
+ ...g,
271
+ views: g.views.filter((v) => !selected.has(selKey(g.id, v.url))),
272
+ })),
273
+ );
274
+ clearSelection();
275
+ }
276
+
277
+ // Move every selected view into `destId` (append; skip views already there).
278
+ function moveSelected(destId: string) {
279
+ if (selectedCount === 0 || !destId) return;
280
+ setGroups((prev) => {
281
+ // Collect the moving views (from any source group), preserving order.
282
+ const moving: ReviewView[] = [];
283
+ for (const g of prev) {
284
+ for (const v of g.views) {
285
+ if (g.id !== destId && selected.has(selKey(g.id, v.url))) moving.push(v);
286
+ }
287
+ }
288
+ if (moving.length === 0) return prev;
289
+ return prev.map((g) => {
290
+ if (g.id === destId) {
291
+ const have = new Set(g.views.map((v) => v.url));
292
+ const added = moving.filter((v) => !have.has(v.url));
293
+ return { ...g, views: [...g.views, ...added] };
294
+ }
295
+ // Drop the moved views from their source group.
296
+ return {
297
+ ...g,
298
+ views: g.views.filter((v) => !selected.has(selKey(g.id, v.url))),
299
+ };
300
+ });
301
+ });
302
+ clearSelection();
303
+ }
304
+
305
+ // Merge `srcId` INTO `destId` — dest keeps its name/id and gains src's views
306
+ // (deduped by handle); src is dropped.
307
+ function mergeInto(srcId: string, destId: string) {
308
+ if (!srcId || !destId || srcId === destId) return;
309
+ setGroups((prev) => {
310
+ const src = prev.find((g) => g.id === srcId);
311
+ if (!src) return prev;
312
+ return prev
313
+ .map((g) => {
314
+ if (g.id !== destId) return g;
315
+ const have = new Set(g.views.map((v) => v.url));
316
+ const added = src.views.filter((v) => !have.has(v.url));
317
+ return { ...g, views: [...g.views, ...added] };
318
+ })
319
+ .filter((g) => g.id !== srcId);
320
+ });
321
+ // Drop any selection that pointed at the vanished source group.
322
+ setSelected((prev) => {
323
+ const next = new Set<string>();
324
+ for (const k of prev) if (!k.startsWith(`${srcId}␟`)) next.add(k);
325
+ return next;
326
+ });
327
+ }
328
+
329
+ function renameGroup(id: string, name: string) {
330
+ setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name } : g)));
331
+ }
332
+ function toggleProceed(id: string) {
333
+ setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, proceed: !g.proceed } : g)));
334
+ }
335
+
336
+ // ── commit (S3 from-groups) ────────────────────────────────────────────────
337
+ const proceedGroups = groups.filter((g) => g.proceed && g.views.length > 0);
338
+ const canCommit = proceedGroups.length > 0 && !committing;
339
+
340
+ async function commit() {
341
+ if (!canCommit) return;
342
+ setCommitting(true);
343
+ setCommitError(null);
344
+ // Ordered payload — results come back positionally aligned to THIS order.
345
+ const ordered = proceedGroups;
346
+ const body = {
347
+ groups: ordered.map((g) => ({
348
+ name: g.name.trim() || g.char,
349
+ reference_images: g.views.map((v) => v.url),
350
+ })),
351
+ };
352
+ const res = await request<unknown>(hugpyConfig.identityProfilesFromGroupsUrl, {
353
+ method: "POST",
354
+ body: JSON.stringify(body),
355
+ headers: { "Content-Type": "application/json" },
356
+ meta: { specKey: "studio", operation: "identity.groups.commit" },
357
+ });
358
+ setCommitting(false);
359
+ if (!res.ok) {
360
+ setCommitError(describeAppError(errorOf(res)));
361
+ return;
362
+ }
363
+ const payload = okValue(res) as { results?: CommitResult[] };
364
+ const results = Array.isArray(payload.results) ? payload.results : [];
365
+ // Zip results back onto the submitted groups by index (positional contract).
366
+ setGroups((prev) =>
367
+ prev.map((g) => {
368
+ const idx = ordered.findIndex((o) => o.id === g.id);
369
+ if (idx < 0) return g;
370
+ const r = results[idx];
371
+ if (!r) return { ...g, error: "No result returned for this group." };
372
+ if (r.ok && r.slug) return { ...g, slug: r.slug, error: null };
373
+ return { ...g, slug: null, error: r.error || "Commit failed for this group." };
374
+ }),
375
+ );
376
+ }
377
+
378
+ // ── S4: per-group 3D generation (dev/CHARACTER-GROUPS-PLAN.md) ────────────
379
+ // Reuses the ALREADY-EXISTING mesh-from-profile backend — POST
380
+ // identityGenerateUrl(slug) with a bare {} body (the documented happy path:
381
+ // Hunyuan3D mesh + 360° turntable + auto-promoted canonical, off the
382
+ // profile's own reference images, no prior reconstruction needed) →
383
+ // {job_id, recon_id}. Then poll identityMeshStatusUrl(slug, recon_id) — the
384
+ // SAME retrieval Studio's generateFullIdentity uses — until the mesh state
385
+ // block reports status:"done" with a glb_path (or a terminal error).
386
+ function setMesh(slug: string, patch: Partial<Mesh3DState>) {
387
+ setMesh3D((prev) => ({ ...prev, [slug]: { ...(prev[slug] || IDLE_3D), ...patch } }));
388
+ }
389
+
390
+ async function generate3D(slug: string) {
391
+ if (!slug) return;
392
+ if (meshActive.current[slug]) return; // already running for this slug
393
+ if (onGenerate3D) onGenerate3D(slug); // optional fire-and-forget notify hook
394
+ meshActive.current[slug] = true;
395
+ setMesh(slug, { phase: "running", status: "Generating… (queued)", glbUrl: null, error: null });
396
+
397
+ const res = await request<unknown>(identityGenerateUrl(slug), {
398
+ method: "POST",
399
+ body: JSON.stringify({}),
400
+ headers: { "Content-Type": "application/json" },
401
+ meta: { specKey: "studio", operation: "identity.groups.generate3d" },
402
+ });
403
+ if (!meshActive.current[slug]) return;
404
+ if (!res.ok) {
405
+ meshActive.current[slug] = false;
406
+ setMesh(slug, { phase: "error", status: null, error: describeAppError(errorOf(res)) });
407
+ return;
408
+ }
409
+ const enq = okValue(res) as { job_id?: string; recon_id?: string };
410
+ const reconId = enq.recon_id;
411
+ if (!reconId) {
412
+ meshActive.current[slug] = false;
413
+ setMesh(slug, { phase: "error", status: null, error: "Malformed generate response." });
414
+ return;
415
+ }
416
+
417
+ const deadline = Date.now() + 20 * 60 * 1000; // ~20 min bound, mirrors Studio's generate poll
418
+ const pollOnce = async () => {
419
+ if (!meshActive.current[slug]) return;
420
+ if (Date.now() > deadline) {
421
+ meshActive.current[slug] = false;
422
+ setMesh(slug, { phase: "error", status: null, error: "Timed out waiting for the 3D build." });
423
+ return;
424
+ }
425
+ const sres = await request<unknown>(identityMeshStatusUrl(slug, reconId), {
426
+ meta: { specKey: "studio", operation: "identity.groups.mesh.status" },
427
+ });
428
+ if (!meshActive.current[slug]) return;
429
+ if (sres.ok) {
430
+ const ms = okValue(sres) as { status?: string; error?: string | null; glb_path?: string | null };
431
+ const st = ms.status ?? "queued";
432
+ if (st === "done") {
433
+ meshActive.current[slug] = false;
434
+ if (ms.glb_path) {
435
+ setMesh(slug, { phase: "done", status: "done", glbUrl: mediaBytesUrl(ms.glb_path), error: null });
436
+ } else {
437
+ setMesh(slug, { phase: "error", status: null, error: "Build finished but returned no model." });
438
+ }
439
+ return;
440
+ }
441
+ if (st === "error" || st === "cancelled") {
442
+ meshActive.current[slug] = false;
443
+ setMesh(slug, { phase: "error", status: null, error: ms.error || `Build ${st}.` });
444
+ return;
445
+ }
446
+ setMesh(slug, { status: `Generating… (${st})` });
447
+ }
448
+ // queued/running/transient-poll-failure → keep polling.
449
+ meshTimer.current[slug] = window.setTimeout(() => void pollOnce(), 5000);
450
+ };
451
+ meshTimer.current[slug] = window.setTimeout(() => void pollOnce(), 5000);
452
+ }
453
+
454
+ // ── render ─────────────────────────────────────────────────────────────────
455
+ return (
456
+ <section
457
+ className="vi-comfy-bar"
458
+ aria-label="Review and curate character groups"
459
+ style={{ marginTop: "0.8rem", flexDirection: "column", alignItems: "stretch" }}
460
+ >
461
+ <div>
462
+ <span className="vi-comfy-label">Character groups — review &amp; curate</span>
463
+ <p className="vi-comfy-hint" style={{ margin: "0.15rem 0 0.4rem" }}>
464
+ Run a NON-committing char360 review over the loaded video: detected characters
465
+ come back as editable groups. Remove, move, or merge images, rename groups, mark
466
+ which ones to proceed, then create identity profiles from the ones you keep.
467
+ </p>
468
+ </div>
469
+
470
+ <div className="vi-comfy-bar-actions vi-run-bar" style={{ flexWrap: "wrap" }}>
471
+ <button
472
+ type="button"
473
+ className="vi-btn vi-btn-accent"
474
+ disabled={!canReview}
475
+ onClick={() => void runReview()}
476
+ >
477
+ {busy ? "Reviewing…" : reviewed ? "Re-run review" : "Review characters"}
478
+ </button>
479
+ {!sourceIsVideo && (
480
+ <span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
481
+ Load a video above to review its characters.
482
+ </span>
483
+ )}
484
+ {status && (
485
+ <span className="vi-comfy-hint" role="status">
486
+ {status}
487
+ </span>
488
+ )}
489
+ </div>
490
+ {error && (
491
+ <p className="vi-error" role="alert" style={{ margin: 0 }}>
492
+ {error}
493
+ </p>
494
+ )}
495
+
496
+ {reviewed && groups.length === 0 && !busy && (
497
+ <p className="vi-comfy-hint">No characters were detected in this video.</p>
498
+ )}
499
+
500
+ {groups.length > 0 && (
501
+ <>
502
+ {/* Cross-group selection toolbar. */}
503
+ <div className="vi-frames-toolbar" style={{ flexWrap: "wrap" }}>
504
+ <span className="vi-frames-summary">
505
+ {groups.length} group{groups.length === 1 ? "" : "s"} · {selectedCount} image
506
+ {selectedCount === 1 ? "" : "s"} selected
507
+ </span>
508
+ <div className="vi-frames-actions" style={{ flexWrap: "wrap" }}>
509
+ <button
510
+ type="button"
511
+ className="vi-btn vi-btn-sm vi-btn-ghost"
512
+ onClick={clearSelection}
513
+ disabled={selectedCount === 0}
514
+ >
515
+ Clear selection
516
+ </button>
517
+ <button
518
+ type="button"
519
+ className="vi-btn vi-btn-sm vi-btn-ghost"
520
+ onClick={removeSelected}
521
+ disabled={selectedCount === 0}
522
+ title="Remove the selected images from their groups"
523
+ >
524
+ Remove selected
525
+ </button>
526
+ <label className="vi-comfy-field" style={{ flexBasis: "auto" }}>
527
+ <span className="vi-comfy-label">Move selected to</span>
528
+ <select
529
+ className="vi-knob-input"
530
+ value=""
531
+ disabled={selectedCount === 0}
532
+ onChange={(e) => {
533
+ if (e.target.value) moveSelected(e.target.value);
534
+ e.target.value = "";
535
+ }}
536
+ >
537
+ <option value="">choose group…</option>
538
+ {groups.map((g) => (
539
+ <option key={g.id} value={g.id}>
540
+ {g.name || g.char}
541
+ </option>
542
+ ))}
543
+ </select>
544
+ </label>
545
+ </div>
546
+ </div>
547
+
548
+ {/* One card per group. */}
549
+ {groups.map((g) => {
550
+ const mergeTargets = groups.filter((o) => o.id !== g.id);
551
+ return (
552
+ <div key={g.id} className="vi-character-tools" style={{ marginTop: "0.5rem" }}>
553
+ <div
554
+ className="vi-character-tools-head"
555
+ style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: "0.6rem" }}
556
+ >
557
+ <label className="vi-comfy-field" style={{ flexBasis: "14rem" }}>
558
+ <span className="vi-comfy-label">Group name</span>
559
+ <input
560
+ type="text"
561
+ className="vi-knob-input"
562
+ value={g.name}
563
+ placeholder={g.char}
564
+ onChange={(e) => renameGroup(g.id, e.target.value)}
565
+ />
566
+ </label>
567
+ <span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
568
+ char360 id: {g.char} · {g.views.length} image{g.views.length === 1 ? "" : "s"}
569
+ </span>
570
+ <label className="vi-knob-check" style={{ marginLeft: "auto" }}>
571
+ <input
572
+ type="checkbox"
573
+ checked={g.proceed}
574
+ onChange={() => toggleProceed(g.id)}
575
+ />
576
+ proceed to commit
577
+ </label>
578
+ {mergeTargets.length > 0 && (
579
+ <label className="vi-comfy-field" style={{ flexBasis: "auto" }}>
580
+ <span className="vi-comfy-label">Merge into</span>
581
+ <select
582
+ className="vi-knob-input"
583
+ value=""
584
+ onChange={(e) => {
585
+ if (e.target.value) mergeInto(g.id, e.target.value);
586
+ e.target.value = "";
587
+ }}
588
+ >
589
+ <option value="">choose group…</option>
590
+ {mergeTargets.map((o) => (
591
+ <option key={o.id} value={o.id}>
592
+ {o.name || o.char}
593
+ </option>
594
+ ))}
595
+ </select>
596
+ </label>
597
+ )}
598
+ </div>
599
+
600
+ {g.views.length === 0 ? (
601
+ <p className="vi-comfy-hint">No images left in this group.</p>
602
+ ) : (
603
+ <div className="vi-frames-grid">
604
+ {g.views.map((v) => {
605
+ const picked = selected.has(selKey(g.id, v.url));
606
+ const parts: string[] = [];
607
+ if (v.yaw != null) parts.push(`yaw ${Math.round(v.yaw)}°`);
608
+ if (v.bin != null) parts.push(`bin ${v.bin}`);
609
+ if (v.score != null) parts.push(`score ${v.score.toFixed(2)}`);
610
+ return (
611
+ <button
612
+ type="button"
613
+ key={v.url}
614
+ className={picked ? "vi-frame-cell vi-frame-cell-selected" : "vi-frame-cell"}
615
+ onClick={() => toggleView(g.id, v.url)}
616
+ aria-pressed={picked}
617
+ title={`${v.url}${parts.length ? ` — ${parts.join(" · ")}` : ""}`}
618
+ >
619
+ <img
620
+ className="vi-frame-img"
621
+ src={mediaBytesUrl(v.url)}
622
+ alt={parts.join(" ") || "character view"}
623
+ loading="lazy"
624
+ />
625
+ {parts.length > 0 && <span className="vi-frame-tag">{parts[0]}</span>}
626
+ {picked && <span className="vi-frame-check">✓</span>}
627
+ </button>
628
+ );
629
+ })}
630
+ </div>
631
+ )}
632
+
633
+ {/* Per-group commit outcome + the S4 "Generate 3D" flow (self-contained —
634
+ see generate3D() above; onGenerate3D, if passed, is fired as an
635
+ optional notify hook only). */}
636
+ {g.slug && (() => {
637
+ const m3 = mesh3D[g.slug] || IDLE_3D;
638
+ const running = m3.phase === "running";
639
+ return (
640
+ <>
641
+ <div className="vi-frames-actions" style={{ flexWrap: "wrap", alignItems: "center" }}>
642
+ <span className="vi-comfy-hint" style={{ color: "var(--vi-accent)" }}>
643
+ ✓ created identity “{g.name || g.char}” ({g.slug})
644
+ </span>
645
+ <button
646
+ type="button"
647
+ className="vi-btn vi-btn-sm vi-btn-accent"
648
+ disabled={running}
649
+ onClick={() => void generate3D(g.slug as string)}
650
+ title={`Generate a 3D model for “${g.name || g.char}” (Hunyuan3D mesh + 360° turntable — takes several minutes)`}
651
+ >
652
+ {running
653
+ ? "Generating 3D…"
654
+ : m3.phase === "done"
655
+ ? "Regenerate 3D"
656
+ : "Generate 3D"}
657
+ </button>
658
+ {m3.status && (m3.phase === "running") && (
659
+ <span className="vi-comfy-hint" role="status">
660
+ {m3.status}
661
+ </span>
662
+ )}
663
+ </div>
664
+ {m3.error && (
665
+ <p className="vi-error" role="alert" style={{ margin: 0 }}>
666
+ {m3.error}
667
+ </p>
668
+ )}
669
+ {m3.phase === "done" && m3.glbUrl && (
670
+ <div style={{ marginTop: "0.4rem" }}>
671
+ <MeshViewer url={m3.glbUrl} height={320} />
672
+ </div>
673
+ )}
674
+ </>
675
+ );
676
+ })()}
677
+ {g.error && (
678
+ <p className="vi-error" role="alert" style={{ margin: 0 }}>
679
+ {g.error}
680
+ </p>
681
+ )}
682
+ </div>
683
+ );
684
+ })}
685
+
686
+ {/* Commit bar. */}
687
+ <div className="vi-comfy-bar-actions vi-run-bar" style={{ flexWrap: "wrap" }}>
688
+ <button
689
+ type="button"
690
+ className="vi-btn vi-btn-accent"
691
+ disabled={!canCommit}
692
+ onClick={() => void commit()}
693
+ title="Create one identity profile per selected group from its kept images"
694
+ >
695
+ {committing
696
+ ? "Creating…"
697
+ : `Create identities from selected groups${
698
+ proceedGroups.length ? ` (${proceedGroups.length})` : ""
699
+ }`}
700
+ </button>
701
+ {proceedGroups.length === 0 && (
702
+ <span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
703
+ Mark at least one non-empty group “proceed to commit”.
704
+ </span>
705
+ )}
706
+ </div>
707
+ {commitError && (
708
+ <p className="vi-error" role="alert" style={{ margin: 0 }}>
709
+ {commitError}
710
+ </p>
711
+ )}
712
+ </>
713
+ )}
714
+ </section>
715
+ );
716
+ }