@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,575 @@
1
+ // IDENTITY PROFILES (studio stage (a)) — the durable "the reference set IS the
2
+ // identity" library, as a hook shared by every surface that touches a profile
3
+ // (the Clip Generate tab, the Movie composer, and the dedicated Identities
4
+ // station). One concept, backed by the server store:
5
+ // • list — GET /video/identity-profiles → { profiles: [...] }
6
+ // • create — POST /video/identity-profiles { name, reference_images[1..4], notes? }
7
+ // • update — PATCH /video/identity-profiles/<slug> { name?, notes?, reference_images? }
8
+ // (partial — an omitted key is left untouched server-side); renaming is
9
+ // DISPLAY-ONLY, the slug never changes (see the backend docstring —
10
+ // templates/specs reference a profile by slug, so re-slugging on rename
11
+ // would silently strand them).
12
+ // • remove — DELETE /video/identity-profiles/<slug> (ARCHIVES, never erases)
13
+ // • resolve — ingest a profile's saved reference paths back into MediaRefs so the
14
+ // attach flow lands them in the SAME state a hand-picked upload does
15
+ // (as if hand-picked — the durable form of the identity, not a copy).
16
+ //
17
+ // Data-not-throws, like useProjects/useStudioClips (from which the fetch idiom is
18
+ // cloned): a failed list just yields []; create returns a discriminated result so a
19
+ // caller can tell a duplicate-name (409) from other failures without a try/catch.
20
+ import { useCallback, useEffect, useRef, useState } from "react";
21
+ import { z } from "zod";
22
+ import { request, okValue, errorOf, describeAppError } from "../../transport/client";
23
+ import {
24
+ hugpyConfig,
25
+ identityProfileUrl,
26
+ identityProfileSettingsUrl,
27
+ identityVersionUrl,
28
+ identityVersionActivateUrl,
29
+ } from "../../config";
30
+ import { mediaRefSchema } from "../../video/contract";
31
+ import type { MediaRef } from "../../video/contract";
32
+
33
+ // ── char360 S4 — VIDEO → CHARACTER VIEW-SET extraction ──────────────────────
34
+ // All-optional knobs for POST /video/identity-profiles/video-extract's
35
+ // `char360_params`; blank/omitted == the service default for every field (the
36
+ // Identities station's Advanced section only sends what the user actually set).
37
+ export const char360ParamsSchema = z
38
+ .object({
39
+ stride: z.number().optional(),
40
+ yolo_model: z.string().optional(),
41
+ min_h_frac: z.number().optional(),
42
+ cluster_dist: z.number().optional(),
43
+ min_faces: z.number().optional(),
44
+ })
45
+ .partial();
46
+ export type Char360Params = z.infer<typeof char360ParamsSchema>;
47
+
48
+ /** enqueue-only outcome of extractFromVideo — polling for the terminal
49
+ * result.ok/result.error lives in the caller (mirrors the /generate poll
50
+ * loop), same reason as CreateProfileResult: a flat shape, not a discriminated
51
+ * union, since the scoped tsconfig runs strictNullChecks OFF. */
52
+ export interface ExtractFromVideoResult {
53
+ ok: boolean;
54
+ jobId?: string;
55
+ message?: string;
56
+ }
57
+
58
+ // ── wire schema (tolerant/passthrough — assert only what the UI reads) ──────────
59
+
60
+ // One TURNAROUND RECONSTRUCTION ("character sheet") a profile has accrued — an
61
+ // additive field the reconstruction flow (POST .../reconstruction) writes: an id,
62
+ // when it was made, the ordered generated view paths (positionally matching the
63
+ // requested view names: front / ¾ / profile / back, each served via mediaBytesUrl),
64
+ // and the prompt/seed it was rolled with. Tolerant/passthrough — every field but
65
+ // recon_id/views is optional so a lean record still parses.
66
+ // src/stations/studio/useIdentityProfiles.ts
67
+
68
+ // ── wire schema (tolerant/passthrough — assert only what the UI reads) ──────────
69
+
70
+ export const reconstructionSchema = z
71
+ .object({
72
+ recon_id: z.string(),
73
+ created_at: z.number().nullable().optional(),
74
+ // Lenient: accept missing views and default to empty array
75
+ views: z.array(z.any()).nullable().optional().transform(v => v || []),
76
+ prompt: z.string().nullable().optional(),
77
+ seed: z.number().nullable().optional(),
78
+ // Lenient: accept any string for mode so legacy ones don't break
79
+ mode: z.string().nullable().optional(),
80
+ frame_count: z.number().nullable().optional(),
81
+ degrees_per_frame: z.number().nullable().optional(),
82
+ mesh: z.any().nullable().optional(),
83
+ })
84
+ .passthrough();
85
+ export type Reconstruction = z.infer<typeof reconstructionSchema>;
86
+
87
+ // ── IDENTITY VERSIONS slice (dev/IDENTITY-VERSIONS-SLICE.md) — additive, every
88
+ // field defaulted so an old (pre-versions) profile payload still parses clean. ──
89
+
90
+ // One named render-set an identity has accrued — the clay base (minted on first
91
+ // Generate, always kept) plus N derived "textured"/"styled" versions. `kind` is
92
+ // lenient: any string that isn't a recognized kind quietly falls back to "clay"
93
+ // rather than failing the whole profile parse.
94
+ export const identityVersionSchema = z
95
+ .object({
96
+ version_id: z.string(),
97
+ name: z.string().nullable().optional().transform(v => v || ""),
98
+ kind: z
99
+ .string()
100
+ .nullable()
101
+ .optional()
102
+ .transform((v): "clay" | "textured" | "styled" => (v === "textured" || v === "styled" ? v : "clay")),
103
+ recon_id: z.string().nullable().optional(),
104
+ created_at: z.number().nullable().optional(),
105
+ canonical: z.array(z.string()).nullable().optional().transform(v => v || []),
106
+ notes: z.string().nullable().optional(),
107
+ })
108
+ .passthrough();
109
+ export type IdentityVersion = z.infer<typeof identityVersionSchema>;
110
+
111
+ // Per-identity generation defaults (the Settings panel + the happy-path bare
112
+ // `/generate` click). Every field individually defaulted so `gen_settings.parse({})`
113
+ // (an entirely absent block, on an old payload) yields the full happy-path object.
114
+ export const genSettingsSchema = z
115
+ .object({
116
+ texture: z.boolean().nullable().optional().transform(v => v ?? true),
117
+ pose: z
118
+ .string()
119
+ .nullable()
120
+ .optional()
121
+ .transform((v): "none" | "t-pose" => (v === "t-pose" ? "t-pose" : "none")),
122
+ frame_count: z.number().nullable().optional().transform(v => v ?? 72),
123
+ fps: z.number().nullable().optional().transform(v => v ?? 24),
124
+ width: z.number().nullable().optional().transform(v => v ?? 768),
125
+ height: z.number().nullable().optional().transform(v => v ?? 768),
126
+ auto_promote: z.boolean().nullable().optional().transform(v => v ?? true),
127
+ front_ref: z.string().nullable().optional().transform(v => v ?? null),
128
+ remove_background: z.boolean().nullable().optional().transform(v => v ?? true),
129
+ // Per-identity VISION MODEL for the 3D-imaging front-select step. null (default)
130
+ // == the fleet-default VL model (the 3B) — zero regression. A non-null value is an
131
+ // image-text-to-text model key (validated server-side against the live registry).
132
+ vision_model: z.string().nullable().optional().transform(v => v ?? null),
133
+ // CLEANUP-PROMPT slice (C4 — Advanced-panel reachability): a positive-worded AVOID
134
+ // instruction woven into the T-pose front render (e.g. "no object on her back, no
135
+ // logos, clean bare back"). "" (default) == no steer, byte-identical to today.
136
+ cleanup_prompt: z.string().nullable().optional().transform(v => v ?? ""),
137
+ // A TRUE negative forwarded to the studio Wan-VACE render (reconstruction + the
138
+ // mesh route's id_lock front render). "" (default) == today's exact call.
139
+ negative_prompt: z.string().nullable().optional().transform(v => v ?? ""),
140
+ })
141
+ .passthrough();
142
+ export type GenSettings = z.infer<typeof genSettingsSchema>;
143
+
144
+ /** The happy-path defaults — a profile with no `gen_settings` block at all (every
145
+ * payload before this slice shipped) resolves to exactly this. */
146
+ export const DEFAULT_GEN_SETTINGS: GenSettings = genSettingsSchema.parse({});
147
+
148
+ export const identityProfileSchema = z
149
+ .object({
150
+ slug: z.string(),
151
+ // Lenient: if any core fields are missing/null, safely default them
152
+ name: z.string().nullable().optional().transform(v => v || "Unnamed"),
153
+ reference_images: z.array(z.string()).nullable().optional().transform(v => v || []),
154
+ created_at: z.number().nullable().optional(),
155
+ notes: z.string().nullable().optional(),
156
+ reconstructions: z.array(reconstructionSchema).nullable().optional().transform(v => v || []),
157
+ canonical: z.array(z.string()).nullable().optional().transform(v => v || []),
158
+ // ADDITIVE per-view angle provenance (dev/CHAR360-FEATURE-PLAN.md's parallel
159
+ // slice) — POSITIONALLY ALIGNED to `canonical` (canonical_angles[i] describes
160
+ // canonical[i]). OMITTED ENTIRELY on any profile from before the backend
161
+ // backfill (identity_profiles.py:1556) — optional with no default-to-[]
162
+ // transform, so "absent" stays distinguishable from "present but empty" for
163
+ // the UI's honest-fallback check (see GenIdentityBar.tsx's viewLabel). An
164
+ // individual element may be `null` (unknown for just that view) rather than a
165
+ // guessed 0.0. The backend already drops the key on a length mismatch with
166
+ // `canonical`, but the UI re-checks the lengths itself before trusting it
167
+ // (never assume the wire honored its own contract).
168
+ canonical_angles: z.array(z.number().nullable()).nullable().optional(),
169
+ // versions[] / active_version / gen_settings may be ABSENT entirely on any
170
+ // payload served before an API restart picks up the backend half of this
171
+ // slice — all three default so the station renders exactly as it does today.
172
+ versions: z.array(identityVersionSchema).nullable().optional().transform(v => v || []),
173
+ active_version: z.string().nullable().optional().transform(v => v ?? null),
174
+ gen_settings: genSettingsSchema
175
+ .nullable()
176
+ .optional()
177
+ .transform(v => v ?? genSettingsSchema.parse({})),
178
+ })
179
+ .passthrough();
180
+ export type IdentityProfile = z.infer<typeof identityProfileSchema>;
181
+
182
+ const listResponseSchema = z.object({ profiles: z.array(identityProfileSchema) }).passthrough();
183
+ const createResponseSchema = z.object({ profile: identityProfileSchema }).passthrough();
184
+
185
+ /** create() outcome — a duplicate name (409) is distinguishable from any other
186
+ * failure WITHOUT a thrown exception. A FLAT shape (not a discriminated union):
187
+ * the scoped tsconfig runs strictNullChecks OFF, under which the repo's Result
188
+ * narrowing is unreliable (hence its okValue/errorOf helpers) — so every field is
189
+ * directly accessible off `ok` instead of relying on control-flow narrowing. */
190
+ export interface CreateProfileResult {
191
+ ok: boolean;
192
+ profile?: IdentityProfile;
193
+ duplicate?: boolean;
194
+ message?: string;
195
+ }
196
+
197
+ /** update() outcome — same flat (not discriminated-union) shape as
198
+ * CreateProfileResult, for the same strictNullChecks-off narrowing reason. A
199
+ * PATCH never 409s (renaming never re-slugs, so it can't collide with another
200
+ * profile's slug) — `message` alone carries any failure (404 unknown slug,
201
+ * 400 bad body, etc.). */
202
+ export interface UpdateProfileResult {
203
+ ok: boolean;
204
+ profile?: IdentityProfile;
205
+ message?: string;
206
+ }
207
+
208
+ /** Outcome shared by every version mutation (activate / rename / archive) — flat
209
+ * (not a discriminated union), same reason as CreateProfileResult/UpdateProfileResult.
210
+ * `profile` is populated whenever the caller could reconcile the identity's new
211
+ * state (either the response echoed it, or a same-slug re-GET did) — never
212
+ * required for `ok` to be true, since the wire contract doesn't fix a response
213
+ * envelope for these three (only the settings PATCH is documented as → {profile}). */
214
+ export interface VersionMutationResult {
215
+ ok: boolean;
216
+ profile?: IdentityProfile;
217
+ message?: string;
218
+ }
219
+
220
+ export interface IdentityProfilesState {
221
+ profiles: IdentityProfile[];
222
+ loading: boolean;
223
+ error: string | null;
224
+ /** Re-pull the list (foreground). */
225
+ reload: () => void;
226
+ /** Save a new profile from a name + already-attached reference URIs. */
227
+ create: (name: string, referenceImages: string[], notes?: string) => Promise<CreateProfileResult>;
228
+ /** Partially edit an existing profile — an omitted field is left untouched
229
+ * server-side. Renaming is display-only (the slug never changes). Passing an
230
+ * empty `referenceImages` array is rejected by the server (an identity keeps
231
+ * >=1 reference); omit the field (undefined) to leave the current set alone. */
232
+ update: (
233
+ slug: string,
234
+ fields: { name?: string; notes?: string; referenceImages?: string[] },
235
+ ) => Promise<UpdateProfileResult>;
236
+ /** Archive a profile by slug (never-delete: the server moves it under _deleted). */
237
+ remove: (slug: string) => Promise<boolean>;
238
+ /** Ingest a profile's saved reference paths → MediaRefs (drops any that fail), so
239
+ * the caller can attach them into its OWN reference state — as if hand-picked. */
240
+ resolveRefs: (profile: IdentityProfile) => Promise<MediaRef[]>;
241
+ /** Ingest a profile's CANONICAL turntable views → MediaRefs, in order (drops any
242
+ * that fail to ingest, but never silently substitutes a different set). This is
243
+ * `profile.canonical` — the top-level field, which already reflects the profile's
244
+ * ACTIVE version (see the `canonical` field doc above) — NOT a walk of
245
+ * `versions[]`. Distinct from `resolveRefs` (which resolves the raw, messy
246
+ * `reference_images` source uploads) so Studio's `IdentityProfileControls` /
247
+ * `StudioMovieComposer` — both still on `resolveRefs` — are byte-for-byte
248
+ * unaffected by this addition. An EMPTY `profile.canonical` resolves to an EMPTY
249
+ * array (never falls back to `reference_images`) — the caller must treat that as
250
+ * an honest failure, not attach nothing silently. */
251
+ resolveCanonical: (profile: IdentityProfile) => Promise<MediaRef[]>;
252
+ /** Ingest ONE of a profile's CANONICAL views (by index into `profile.canonical`)
253
+ * → a MediaRef, or null when the index is out of range or the image won't ingest.
254
+ *
255
+ * Additive sibling to `resolveCanonical`, for the Generate station's
256
+ * one-carrying-view bind: the generate runner consumes only the FIRST image part
257
+ * (imagegen.py:117 `start_frame = image_paths[0]`), so a bind attaches exactly one
258
+ * view — resolving the whole set to keep a single ref would ingest N-1 images for
259
+ * nothing (visible cost once canonical grows to 8 azimuth views). Returns null
260
+ * rather than throwing: the caller turns it into a loud, human message and NEVER
261
+ * falls back to `reference_images`. `resolveRefs` / `resolveCanonical` are both
262
+ * untouched — Studio is byte-for-byte unaffected. */
263
+ resolveCanonicalView: (
264
+ profile: IdentityProfile,
265
+ index: number,
266
+ ) => Promise<MediaRef | null>;
267
+ /** Partially save this identity's generation settings (texture/pose/turntable
268
+ * geometry/auto-promote/front-ref/background-removal) — an omitted key is left
269
+ * untouched server-side, same partial-PATCH idiom as `update`. Prefills the
270
+ * happy-path bare `/generate` click. */
271
+ saveSettings: (slug: string, fields: Partial<GenSettings>) => Promise<UpdateProfileResult>;
272
+ /** Make one version the identity's ACTIVE version — the resolver takes ITS
273
+ * canonical as the id_lock DNA for any future generation that doesn't name a
274
+ * specific `identity_version`. */
275
+ activateVersion: (slug: string, versionId: string) => Promise<VersionMutationResult>;
276
+ /** Rename one version (display-only, mirrors profile rename). */
277
+ renameVersion: (slug: string, versionId: string, name: string) => Promise<VersionMutationResult>;
278
+ /** Archive one version (never-delete: recoverable, not erased). The server
279
+ * refuses this with a 400 for the base (clay) version and for the currently
280
+ * active version — surfaced via `message`. */
281
+ archiveVersion: (slug: string, versionId: string) => Promise<VersionMutationResult>;
282
+ /** char360 S4 — enqueue a video → character-view-set extraction: POST
283
+ * /video/identity-profiles/video-extract { source, target, char360_params? }
284
+ * → { job_id }. `target` is "create" (mints new profile(s)) or an existing
285
+ * profile's slug (adds views to it). ENQUEUE ONLY — this just returns the
286
+ * job id; the caller polls `videoJobUrl(jobId)` (GET /video/jobs/<id>) until
287
+ * `result.ok` is true/false, same poll shape as the /generate flow, then
288
+ * calls `reload()` on success (there is no slug list in the payload). */
289
+ extractFromVideo: (
290
+ source: MediaRef,
291
+ target: "create" | string,
292
+ params?: Char360Params,
293
+ ) => Promise<ExtractFromVideoResult>;
294
+ }
295
+
296
+ /** Ingest ONE server path (already under the storage jail — a profile only ever
297
+ * stores jailed paths) into a resolved MediaRef, or null if it can't be resolved.
298
+ * Reuses POST /video/ingest, the exact first hop the upload flow already runs. */
299
+ async function ingestPath(path: string): Promise<MediaRef | null> {
300
+ const res = await request<unknown>(hugpyConfig.videoIngestUrl, {
301
+ method: "POST",
302
+ body: JSON.stringify({ path }),
303
+ headers: { "Content-Type": "application/json" },
304
+ meta: { specKey: "studio", operation: "identity.profile.ingest" },
305
+ });
306
+ if (!res.ok) return null;
307
+ const parsed = mediaRefSchema.safeParse(okValue(res));
308
+ return parsed.success ? parsed.data : null;
309
+ }
310
+
311
+ export function useIdentityProfiles(): IdentityProfilesState {
312
+ const [profiles, setProfiles] = useState<IdentityProfile[]>([]);
313
+ const [loading, setLoading] = useState(true);
314
+ const [error, setError] = useState<string | null>(null);
315
+
316
+ const mounted = useRef(true);
317
+ const inFlight = useRef(false);
318
+
319
+ const load = useCallback(async () => {
320
+ if (inFlight.current) return;
321
+ inFlight.current = true;
322
+ try {
323
+ const res = await request<unknown>(hugpyConfig.identityProfilesUrl, {
324
+ meta: { specKey: "studio", operation: "identity.profiles.list" },
325
+ });
326
+ if (!mounted.current) return;
327
+ if (!res.ok) {
328
+ setLoading(false);
329
+ setError(describeAppError(errorOf(res)));
330
+ return;
331
+ }
332
+ const parsed = listResponseSchema.safeParse(okValue(res));
333
+ if (!parsed.success) {
334
+ console.error("Profile parsing error:", parsed.error); // Add this line!
335
+ setLoading(false);
336
+ setError("Malformed identity-profiles response.");
337
+ return;
338
+ }
339
+ setProfiles(parsed.data.profiles);
340
+ setLoading(false);
341
+ setError(null);
342
+ } finally {
343
+ inFlight.current = false;
344
+ }
345
+ }, []);
346
+
347
+ useEffect(() => {
348
+ mounted.current = true;
349
+ void load();
350
+ return () => {
351
+ mounted.current = false;
352
+ };
353
+ }, [load]);
354
+
355
+ const create = useCallback<IdentityProfilesState["create"]>(
356
+ async (name, referenceImages, notes) => {
357
+ const res = await request<unknown>(hugpyConfig.identityProfilesUrl, {
358
+ method: "POST",
359
+ body: JSON.stringify({
360
+ name,
361
+ reference_images: referenceImages,
362
+ ...(notes ? { notes } : {}),
363
+ }),
364
+ headers: { "Content-Type": "application/json" },
365
+ meta: { specKey: "studio", operation: "identity.profile.create" },
366
+ });
367
+ if (!res.ok) {
368
+ const err = errorOf(res);
369
+ const duplicate = err.kind === "server" && err.status === 409;
370
+ return { ok: false, duplicate, message: describeAppError(err) };
371
+ }
372
+ const parsed = createResponseSchema.safeParse(okValue(res));
373
+ if (!parsed.success) {
374
+ return { ok: false, duplicate: false, message: "Malformed create response." };
375
+ }
376
+ const profile = parsed.data.profile;
377
+ // Optimistic prepend (newest-first, matching the server sort) so the new
378
+ // profile is pickable immediately; a later reload reconciles.
379
+ if (mounted.current) setProfiles((prev) => [profile, ...prev.filter((p) => p.slug !== profile.slug)]);
380
+ return { ok: true, profile };
381
+ },
382
+ [],
383
+ );
384
+
385
+ const update = useCallback<IdentityProfilesState["update"]>(async (slug, fields) => {
386
+ const body: Record<string, unknown> = {};
387
+ if (fields.name !== undefined) body.name = fields.name;
388
+ if (fields.notes !== undefined) body.notes = fields.notes;
389
+ if (fields.referenceImages !== undefined) body.reference_images = fields.referenceImages;
390
+ const res = await request<unknown>(identityProfileUrl(slug), {
391
+ method: "PATCH",
392
+ body: JSON.stringify(body),
393
+ headers: { "Content-Type": "application/json" },
394
+ meta: { specKey: "studio", operation: "identity.profile.update" },
395
+ });
396
+ if (!res.ok) {
397
+ return { ok: false, message: describeAppError(errorOf(res)) };
398
+ }
399
+ // The PATCH response is the same {profile} envelope create() parses.
400
+ const parsed = createResponseSchema.safeParse(okValue(res));
401
+ if (!parsed.success) {
402
+ return { ok: false, message: "Malformed update response." };
403
+ }
404
+ const profile = parsed.data.profile;
405
+ // Replace in place (slug is stable across a PATCH, so this is a straight swap,
406
+ // never a re-sort); a later reload still reconciles.
407
+ if (mounted.current) {
408
+ setProfiles((prev) => prev.map((p) => (p.slug === profile.slug ? profile : p)));
409
+ }
410
+ return { ok: true, profile };
411
+ }, []);
412
+
413
+ const remove = useCallback(async (slug: string) => {
414
+ const res = await request<unknown>(identityProfileUrl(slug), {
415
+ method: "DELETE",
416
+ meta: { specKey: "studio", operation: "identity.profile.delete" },
417
+ });
418
+ if (!res.ok) return false;
419
+ if (mounted.current) setProfiles((prev) => prev.filter((p) => p.slug !== slug));
420
+ return true;
421
+ }, []);
422
+
423
+ const resolveRefs = useCallback(async (profile: IdentityProfile) => {
424
+ const refs = await Promise.all(profile.reference_images.map((p) => ingestPath(p)));
425
+ return refs.filter((r): r is MediaRef => r != null);
426
+ }, []);
427
+
428
+ // Additive sibling to resolveRefs — resolves the profile's CANONICAL turntable
429
+ // views (top-level `profile.canonical`, already active-version-aware) instead of
430
+ // the raw `reference_images` source uploads. Order is preserved (ref_00..ref_03,
431
+ // 90° apart) since it is semantic for turntable views — Promise.all over an
432
+ // already-ordered array preserves index order regardless of individual ingest
433
+ // timing. resolveRefs is left completely untouched so Studio's
434
+ // IdentityProfileControls / StudioMovieComposer keep today's exact behavior.
435
+ const resolveCanonical = useCallback(async (profile: IdentityProfile) => {
436
+ const refs = await Promise.all(profile.canonical.map((p) => ingestPath(p)));
437
+ return refs.filter((r): r is MediaRef => r != null);
438
+ }, []);
439
+
440
+ // Additive sibling to resolveCanonical — ingests exactly ONE canonical view (the
441
+ // Generate station's carrying view). Bounds-checked because the index comes from a
442
+ // UI row that may have been rendered against a since-refreshed profile list: an
443
+ // out-of-range index must resolve to null (→ a loud caller message), never to the
444
+ // wrong view. resolveRefs / resolveCanonical are untouched.
445
+ const resolveCanonicalView = useCallback(
446
+ async (profile: IdentityProfile, index: number) => {
447
+ const path = profile.canonical[index];
448
+ if (path == null) return null;
449
+ return await ingestPath(path);
450
+ },
451
+ [],
452
+ );
453
+
454
+ const saveSettings = useCallback<IdentityProfilesState["saveSettings"]>(async (slug, fields) => {
455
+ const res = await request<unknown>(identityProfileSettingsUrl(slug), {
456
+ method: "PATCH",
457
+ body: JSON.stringify(fields),
458
+ headers: { "Content-Type": "application/json" },
459
+ meta: { specKey: "studio", operation: "identity.profile.settings" },
460
+ });
461
+ if (!res.ok) {
462
+ return { ok: false, message: describeAppError(errorOf(res)) };
463
+ }
464
+ // Documented envelope for this one — → {profile}, the same shape update() parses.
465
+ const parsed = createResponseSchema.safeParse(okValue(res));
466
+ if (!parsed.success) {
467
+ return { ok: false, message: "Malformed settings response." };
468
+ }
469
+ const profile = parsed.data.profile;
470
+ if (mounted.current) {
471
+ setProfiles((prev) => prev.map((p) => (p.slug === profile.slug ? profile : p)));
472
+ }
473
+ return { ok: true, profile };
474
+ }, []);
475
+
476
+ /** Shared runner for the three version verbs below. The wire contract only fixes
477
+ * a response envelope for the settings PATCH — activate/rename/archive aren't
478
+ * documented, so this tries the fast path (the response itself echoes {profile})
479
+ * and otherwise falls back to a same-slug re-GET (`identityProfileUrl`, already
480
+ * documented as → {profile}) so local state reconciles regardless of what shape
481
+ * the backend ships once it's live. */
482
+ const runVersionMutation = useCallback(
483
+ async (slug: string, url: string, method: string, body?: unknown): Promise<VersionMutationResult> => {
484
+ const res = await request<unknown>(url, {
485
+ method,
486
+ ...(body !== undefined
487
+ ? { body: JSON.stringify(body), headers: { "Content-Type": "application/json" } }
488
+ : {}),
489
+ meta: { specKey: "studio", operation: "identity.version.mutate" },
490
+ });
491
+ if (!res.ok) {
492
+ return { ok: false, message: describeAppError(errorOf(res)) };
493
+ }
494
+ const direct = createResponseSchema.safeParse(okValue(res));
495
+ let profile: IdentityProfile | undefined = direct.success ? direct.data.profile : undefined;
496
+ if (!profile) {
497
+ const refetch = await request<unknown>(identityProfileUrl(slug), {
498
+ meta: { specKey: "studio", operation: "identity.profile.refetch" },
499
+ });
500
+ if (refetch.ok) {
501
+ const rp = createResponseSchema.safeParse(okValue(refetch));
502
+ if (rp.success) profile = rp.data.profile;
503
+ }
504
+ }
505
+ if (profile && mounted.current) {
506
+ const resolved = profile;
507
+ setProfiles((prev) => prev.map((p) => (p.slug === resolved.slug ? resolved : p)));
508
+ }
509
+ return { ok: true, profile };
510
+ },
511
+ [],
512
+ );
513
+
514
+ const extractFromVideo = useCallback<IdentityProfilesState["extractFromVideo"]>(
515
+ async (source, target, params) => {
516
+ const parsedParams = params ? char360ParamsSchema.safeParse(params) : undefined;
517
+ const body: Record<string, unknown> = {
518
+ source,
519
+ target,
520
+ ...(parsedParams && parsedParams.success && Object.keys(parsedParams.data).length > 0
521
+ ? { char360_params: parsedParams.data }
522
+ : {}),
523
+ };
524
+ const res = await request<unknown>(hugpyConfig.videoExtractUrl, {
525
+ method: "POST",
526
+ body: JSON.stringify(body),
527
+ headers: { "Content-Type": "application/json" },
528
+ meta: { specKey: "studio", operation: "identity.video_extract.enqueue" },
529
+ });
530
+ if (!res.ok) {
531
+ return { ok: false, message: describeAppError(errorOf(res)) };
532
+ }
533
+ const enq = okValue(res) as { job_id?: string };
534
+ if (!enq.job_id) {
535
+ return { ok: false, message: "Malformed video-extract response." };
536
+ }
537
+ return { ok: true, jobId: enq.job_id };
538
+ },
539
+ [],
540
+ );
541
+
542
+ const activateVersion = useCallback<IdentityProfilesState["activateVersion"]>(
543
+ (slug, versionId) => runVersionMutation(slug, identityVersionActivateUrl(slug, versionId), "POST"),
544
+ [runVersionMutation],
545
+ );
546
+
547
+ const renameVersion = useCallback<IdentityProfilesState["renameVersion"]>(
548
+ (slug, versionId, name) =>
549
+ runVersionMutation(slug, identityVersionUrl(slug, versionId), "PATCH", { name }),
550
+ [runVersionMutation],
551
+ );
552
+
553
+ const archiveVersion = useCallback<IdentityProfilesState["archiveVersion"]>(
554
+ (slug, versionId) => runVersionMutation(slug, identityVersionUrl(slug, versionId), "DELETE"),
555
+ [runVersionMutation],
556
+ );
557
+
558
+ return {
559
+ profiles,
560
+ loading,
561
+ error,
562
+ reload: () => void load(),
563
+ create,
564
+ update,
565
+ remove,
566
+ resolveRefs,
567
+ resolveCanonical,
568
+ resolveCanonicalView,
569
+ saveSettings,
570
+ activateVersion,
571
+ renameVersion,
572
+ archiveVersion,
573
+ extractFromVideo,
574
+ };
575
+ }