@hugpy/video-intelligence-ui 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/package.json +1 -3
  2. package/src/config.ts +36 -0
  3. package/src/demo/assets/generate/cinema-cavern/movie.mp4 +0 -0
  4. package/src/demo/assets/generate/cinema-cavern/seg_00.mp4 +0 -0
  5. package/src/demo/assets/generate/clips/cat-lounge.mp4 +0 -0
  6. package/src/demo/assets/generate/clips/drone-city.mp4 +0 -0
  7. package/src/demo/assets/generate/clips/paper-sails.mp4 +0 -0
  8. package/src/demo/assets/generate/movie-galaxy/movie.mp4 +0 -0
  9. package/src/demo/assets/generate/movie-galaxy/seg_00.mp4 +0 -0
  10. package/src/demo/assets/generate/movie-galaxy/seg_01.mp4 +0 -0
  11. package/src/demo/assets/generate/movie-galaxy/seg_02.mp4 +0 -0
  12. package/src/demo/assets/generate/movie-galaxy/seg_03.mp4 +0 -0
  13. package/src/demo/assets/generate/movie-medow/movie.mp4 +0 -0
  14. package/src/demo/assets/generate/movie-medow/seg_00.mp4 +0 -0
  15. package/src/demo/assets/generate/movie-medow/seg_01.mp4 +0 -0
  16. package/src/demo/assets/generate/scene-desert/frame_00000.png +0 -0
  17. package/src/demo/assets/generate/scene-desert/frame_00001.png +0 -0
  18. package/src/demo/assets/generate/scene-desert/frame_00002.png +0 -0
  19. package/src/demo/assets/generate/scene-desert/frame_00003.png +0 -0
  20. package/src/demo/assets/generate/scene-desert/frame_00004.png +0 -0
  21. package/src/demo/assets/generate/scene-desert/frame_00005.png +0 -0
  22. package/src/demo/assets/generate/scene-desert/scene.mp4 +0 -0
  23. package/src/demo/assets/generate/scene-vintage/frame_00000.png +0 -0
  24. package/src/demo/assets/generate/scene-vintage/frame_00001.png +0 -0
  25. package/src/demo/assets/generate/scene-vintage/frame_00002.png +0 -0
  26. package/src/demo/assets/generate/scene-vintage/frame_00003.png +0 -0
  27. package/src/demo/assets/generate/scene-vintage/frame_00004.png +0 -0
  28. package/src/demo/assets/generate/scene-vintage/frame_00005.png +0 -0
  29. package/src/demo/assets/generate/scene-vintage/scene.mp4 +0 -0
  30. package/src/demo/assets/generate2/s0.png +0 -0
  31. package/src/demo/assets/generate2/s1.png +0 -0
  32. package/src/demo/assets/generate2/s10.png +0 -0
  33. package/src/demo/assets/generate2/s11.png +0 -0
  34. package/src/demo/assets/generate2/s12.png +0 -0
  35. package/src/demo/assets/generate2/s13.png +0 -0
  36. package/src/demo/assets/generate2/s14.png +0 -0
  37. package/src/demo/assets/generate2/s15.png +0 -0
  38. package/src/demo/assets/generate2/s16.png +0 -0
  39. package/src/demo/assets/generate2/s17.png +0 -0
  40. package/src/demo/assets/generate2/s18.png +0 -0
  41. package/src/demo/assets/generate2/s19.png +0 -0
  42. package/src/demo/assets/generate2/s2.png +0 -0
  43. package/src/demo/assets/generate2/s20.png +0 -0
  44. package/src/demo/assets/generate2/s21.mp4 +0 -0
  45. package/src/demo/assets/generate2/s22.mp4 +0 -0
  46. package/src/demo/assets/generate2/s23.mp4 +0 -0
  47. package/src/demo/assets/generate2/s24.mp4 +0 -0
  48. package/src/demo/assets/generate2/s25.mp4 +0 -0
  49. package/src/demo/assets/generate2/s26.mp4 +0 -0
  50. package/src/demo/assets/generate2/s27.mp4 +0 -0
  51. package/src/demo/assets/generate2/s28.mp4 +0 -0
  52. package/src/demo/assets/generate2/s29.mp4 +0 -0
  53. package/src/demo/assets/generate2/s3.png +0 -0
  54. package/src/demo/assets/generate2/s30.mp4 +0 -0
  55. package/src/demo/assets/generate2/s4.png +0 -0
  56. package/src/demo/assets/generate2/s5.png +0 -0
  57. package/src/demo/assets/generate2/s6.png +0 -0
  58. package/src/demo/assets/generate2/s7.png +0 -0
  59. package/src/demo/assets/generate2/s8.png +0 -0
  60. package/src/demo/assets/generate2/s9.png +0 -0
  61. package/src/demo/demoFetch.ts +126 -3
  62. package/src/demo/fixtures.ts +72 -4
  63. package/src/demo/promptAssist.ts +25 -0
  64. package/src/demo/sampleData.ts +960 -0
  65. package/src/demo/sampleSets.ts +172 -0
  66. package/src/stations/ActiveProcessesStation.tsx +18 -3
  67. package/src/stations/GenerateStation.tsx +901 -366
  68. package/src/stations/GoalComposer.tsx +9 -1
  69. package/src/stations/ScriptFirstStation.tsx +1081 -0
  70. package/src/stations/ToksPanel.tsx +188 -0
  71. package/src/stations/WorkbenchDrawer.tsx +4 -3
  72. package/src/stations/WorkbenchSidebar.tsx +6 -3
  73. package/src/stations/oracle/DagPanel.tsx +391 -0
  74. package/src/stations/oracle/OracleStation.tsx +304 -0
  75. package/src/stations/oracle/SpatialTab.tsx +185 -0
  76. package/src/stations/oracle/StewardPanel.tsx +165 -0
  77. package/src/stations/oracle/oracle.css +467 -0
  78. package/src/stations/oracle/oracleShared.tsx +160 -0
  79. package/src/stations/oracle/oracleTypes.ts +304 -0
  80. package/src/stations/oracle/useOracle.ts +560 -0
  81. package/src/stations/registry.ts +37 -0
  82. package/src/stations/scriptFirst.css +350 -0
  83. package/src/stations/studio/IdentitiesStation.tsx +219 -292
  84. package/src/stations/studio/IdentityCreatePanel.tsx +390 -0
  85. package/src/stations/studio/StudioGenerateTab.tsx +10 -17
  86. package/src/stations/studio/StudioMovieComposer.tsx +296 -59
  87. package/src/stations/studio/StudioPlane.tsx +35 -3
  88. package/src/stations/studio/StudioViewer.tsx +116 -14
  89. package/src/stations/studio/coordinationBadge.tsx +206 -0
  90. package/src/stations/studio/identityUpload.ts +66 -0
  91. package/src/stations/studio/promptCard.tsx +362 -59
  92. package/src/stations/studio/useIdentityCreateJob.ts +184 -0
  93. package/src/stations/studio/useIdentityProfiles.ts +66 -0
  94. package/src/stations/useScriptFirstRun.ts +596 -0
  95. package/src/style/app.css +157 -0
  96. package/src/video/LengthRow.tsx +189 -0
  97. package/src/video/coordinationReport.ts +266 -0
  98. package/src/video/jobClock.ts +104 -0
  99. package/src/video/spreadAssist.ts +26 -0
  100. package/src/video/useMediaJobs.ts +26 -0
  101. package/src/video/useOperatorHistory.ts +5 -0
  102. package/src/video/useToks.ts +193 -0
@@ -0,0 +1,390 @@
1
+ // k94 — the Identities tab's ONE explicit path (the clownworld MO).
2
+ //
3
+ // Create an identity
4
+ // name: [__________]
5
+ // [+ video] [+ images] ← inline expansion (upload · library pick) in the same row
6
+ // ( Create identity ) ← ONE button; disabled until name + a source exist
7
+ // progress: stage · % · log tail
8
+ // > advanced ← everything else, collapsed, default-hidden
9
+ //
10
+ // Input: a video (or a few photos) of a character. Output: an identity profile with
11
+ // reference views + a 3D GLB, ready to bind from "+ identity". No knobs on the happy
12
+ // path — the pipeline answers every question it can answer itself (front view, mesh
13
+ // params, turntable, canonical promotion).
14
+ //
15
+ // + video → POST /video/identity-profiles/from-video {source, name}
16
+ // ONE chained char360 + Hunyuan3D job on the render service; on done ONE
17
+ // profile per detected character (slug, slug-2, …).
18
+ // + images → POST /video/identity-profiles/from-images {sources, name}
19
+ // create profile + the full 3D build, chained server-side.
20
+ // Both hand back a job id watched by useIdentityCreateJob (GET /video/jobs/<id>).
21
+ import { useCallback, useEffect, useRef, useState } from "react";
22
+ import type { ReactNode } from "react";
23
+ import { mediaBytesUrl } from "../../config";
24
+ import type { LibraryItem } from "../../video/mediaLibrary";
25
+ import type { MediaRef } from "../../video/contract";
26
+ import type { IdentityProfilesState } from "./useIdentityProfiles";
27
+ import { LibraryImageGrid } from "./studioShared";
28
+ import { uploadAndIngestImage, uploadAndIngestVideo } from "./identityUpload";
29
+ import { useIdentityCreateJob, describeCreateJob } from "./useIdentityCreateJob";
30
+ import type { CreatedIdentity } from "./useIdentityCreateJob";
31
+
32
+ // Mirrors the catalogue's MAX_SOURCE_IMAGES (identity_profiles.py).
33
+ const MAX_SOURCE_IMAGES = 12;
34
+
35
+ type SourceMode = "video" | "images";
36
+
37
+ /** The ONE-LINE explanation the tab keeps (k94 removed the option-explaining prose). */
38
+ export const ONE_LINE =
39
+ "A video or a few photos in — a bindable character with reference views and a 3D model out.";
40
+
41
+ /**
42
+ * The `> advanced` expander — the PromptCardSettings idiom (a native <details>,
43
+ * the open flag lives in the DOM, never in state) with an `open` default so the
44
+ * sidebar "settings" tab can lead with it expanded. Same CSS hooks as
45
+ * PromptCardSettings (vi-prompt-card-settings*), so it renders identically.
46
+ */
47
+ export function AdvancedExpander({
48
+ label = "advanced",
49
+ open = false,
50
+ children,
51
+ }: {
52
+ label?: string;
53
+ open?: boolean;
54
+ children: ReactNode;
55
+ }) {
56
+ return (
57
+ <details className="vi-prompt-card-settings" open={open}>
58
+ <summary className="vi-prompt-card-settings-summary">{label}</summary>
59
+ <div className="vi-prompt-card-settings-body">{children}</div>
60
+ </details>
61
+ );
62
+ }
63
+
64
+ function fileName(ref: MediaRef): string {
65
+ return ref.uri.split("/").pop() || ref.asset_id;
66
+ }
67
+
68
+ /** A small library-VIDEO list — the video sibling of LibraryImageGrid (which
69
+ * renders <img> thumbnails, wrong for a clip). Each row is a plain <button>. */
70
+ function LibraryVideoList({
71
+ videos,
72
+ onPick,
73
+ }: {
74
+ videos: LibraryItem[];
75
+ onPick: (ref: MediaRef) => void;
76
+ }) {
77
+ if (videos.length === 0) {
78
+ return (
79
+ <p className="vi-comfy-hint" style={{ margin: "0.4rem 0 0" }}>
80
+ No clips in the session library yet — upload a video, or produce one in Scene/Movie.
81
+ </p>
82
+ );
83
+ }
84
+ return (
85
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.35rem", marginTop: "0.4rem", maxHeight: "9rem", overflowY: "auto" }}>
86
+ {videos.map((it) => (
87
+ <button
88
+ key={it.ref.uri}
89
+ type="button"
90
+ className="vi-btn vi-btn-sm vi-btn-ghost"
91
+ onClick={() => onPick(it.ref)}
92
+ title={it.ref.uri}
93
+ style={{ maxWidth: "16rem", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
94
+ >
95
+ ▶ {it.label ?? fileName(it.ref)}
96
+ {it.ref.duration_s ? ` · ${it.ref.duration_s.toFixed(1)}s` : ""}
97
+ </button>
98
+ ))}
99
+ </div>
100
+ );
101
+ }
102
+
103
+ export function IdentityCreatePanel({
104
+ createFromVideo,
105
+ createFromImages,
106
+ library,
107
+ reload,
108
+ onCreated,
109
+ advanced,
110
+ advancedOpen = false,
111
+ }: {
112
+ createFromVideo: IdentityProfilesState["createFromVideo"];
113
+ createFromImages: IdentityProfilesState["createFromImages"];
114
+ library: LibraryItem[];
115
+ reload: () => void;
116
+ /** Fires when the job lands — `slugs` are the profiles now bindable. */
117
+ onCreated: (slugs: string[]) => void;
118
+ /** The `> advanced` body (legacy create panel, char360 knobs, …). */
119
+ advanced?: ReactNode;
120
+ advancedOpen?: boolean;
121
+ }) {
122
+ const [name, setName] = useState("");
123
+ const [mode, setMode] = useState<SourceMode | null>(null);
124
+ const [video, setVideo] = useState<MediaRef | null>(null);
125
+ const [images, setImages] = useState<MediaRef[]>([]);
126
+ const [showLib, setShowLib] = useState(false);
127
+ const [uploading, setUploading] = useState(false);
128
+ const [pickError, setPickError] = useState<string | null>(null);
129
+ const [busy, setBusy] = useState(false);
130
+ const job = useIdentityCreateJob();
131
+ const jobSlug = useRef<string | null>(null);
132
+
133
+ const libraryVideos = library.filter((it) => it.ref.kind === "video");
134
+ const libraryImages = library.filter((it) => it.ref.kind === "image");
135
+
136
+ // A reload after the job lands so the new profile(s) + mesh show; done once.
137
+ useEffect(() => {
138
+ if (job.view.phase === "done" || job.view.phase === "error") {
139
+ setBusy(false);
140
+ reload();
141
+ }
142
+ // eslint-disable-next-line react-hooks/exhaustive-deps
143
+ }, [job.view.phase]);
144
+
145
+ const toggleMode = (m: SourceMode) => {
146
+ setPickError(null);
147
+ setShowLib(false);
148
+ setMode((cur) => (cur === m ? null : m));
149
+ };
150
+
151
+ const addImage = useCallback((ref: MediaRef) => {
152
+ setImages((prev) =>
153
+ prev.length >= MAX_SOURCE_IMAGES || prev.some((r) => r.uri === ref.uri) ? prev : [...prev, ref],
154
+ );
155
+ }, []);
156
+
157
+ async function onPickVideoFile(file: File) {
158
+ setPickError(null);
159
+ setUploading(true);
160
+ const { ref, error } = await uploadAndIngestVideo(file);
161
+ setUploading(false);
162
+ if (error) setPickError(error);
163
+ else if (ref) setVideo(ref);
164
+ }
165
+
166
+ async function onPickImageFiles(files: FileList) {
167
+ setPickError(null);
168
+ setUploading(true);
169
+ const list = Array.from(files as ArrayLike<File>).slice(0, Math.max(0, MAX_SOURCE_IMAGES - images.length));
170
+ const results = await Promise.all(list.map((f) => uploadAndIngestImage(f)));
171
+ setUploading(false);
172
+ for (const r of results) {
173
+ if (r.ref) addImage(r.ref);
174
+ else if (r.error) setPickError(r.error);
175
+ }
176
+ }
177
+
178
+ const hasSource = mode === "video" ? video != null : mode === "images" ? images.length > 0 : false;
179
+ const canCreate = name.trim().length > 0 && hasSource && !busy && !uploading;
180
+
181
+ async function create() {
182
+ if (!canCreate || !mode) return;
183
+ const trimmed = name.trim();
184
+ setBusy(true);
185
+ job.reset();
186
+ const res =
187
+ mode === "video" && video
188
+ ? await createFromVideo(video, trimmed)
189
+ : await createFromImages(images, trimmed);
190
+ if (!res.ok || !res.jobId) {
191
+ setBusy(false);
192
+ job.fail(
193
+ res.duplicate
194
+ ? `An identity named "${trimmed}" already exists — choose another name.`
195
+ : res.message || "Could not start the identity build.",
196
+ );
197
+ return;
198
+ }
199
+ jobSlug.current = res.slug ?? null;
200
+ // The from-images profile exists already — make it selectable right away.
201
+ if (mode === "images" && res.slug) onCreated([res.slug]);
202
+ job.watch(res.jobId, (ids: CreatedIdentity[]) => {
203
+ const slugs = ids.map((i) => i.slug);
204
+ if (slugs.length === 0 && jobSlug.current) slugs.push(jobSlug.current);
205
+ onCreated(slugs);
206
+ // Clear the form for the next character; the progress line stays ("done").
207
+ setName("");
208
+ setVideo(null);
209
+ setImages([]);
210
+ setMode(null);
211
+ });
212
+ }
213
+
214
+ const progress = describeCreateJob(job.view);
215
+ const createdCount = job.view.identities.length;
216
+
217
+ return (
218
+ <section className="vi-comfy-bar vi-id-create" aria-label="Create an identity"
219
+ style={{ display: "flex", flexDirection: "column", gap: "0.5rem", alignItems: "stretch" }}>
220
+ <div>
221
+ <span className="vi-comfy-label" style={{ fontSize: "0.95rem" }}>Create an identity</span>
222
+ <p className="vi-comfy-hint" style={{ margin: "0.15rem 0 0", opacity: 0.8 }}>{ONE_LINE}</p>
223
+ </div>
224
+
225
+ {/* name + [+ video] [+ images] — one row */}
226
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", alignItems: "center" }}>
227
+ <label className="vi-comfy-field" style={{ flex: "1 1 14rem", maxWidth: "22rem" }}>
228
+ <span className="vi-comfy-label">name</span>
229
+ <input
230
+ type="text"
231
+ className="vi-knob-input"
232
+ value={name}
233
+ placeholder="e.g. Mira"
234
+ disabled={busy}
235
+ onChange={(e) => setName(e.target.value)}
236
+ />
237
+ </label>
238
+ <button
239
+ type="button"
240
+ className={`vi-btn vi-btn-sm ${mode === "video" ? "vi-btn-accent" : "vi-btn-ghost"}`}
241
+ aria-expanded={mode === "video"}
242
+ disabled={busy}
243
+ onClick={() => toggleMode("video")}
244
+ title="A video of the character — every person in it becomes an identity"
245
+ >
246
+ + video{video && mode !== "video" ? " ✓" : ""}
247
+ </button>
248
+ <button
249
+ type="button"
250
+ className={`vi-btn vi-btn-sm ${mode === "images" ? "vi-btn-accent" : "vi-btn-ghost"}`}
251
+ aria-expanded={mode === "images"}
252
+ disabled={busy}
253
+ onClick={() => toggleMode("images")}
254
+ title="A few photos of the character (up to 12)"
255
+ >
256
+ + images{images.length && mode !== "images" ? ` (${images.length})` : ""}
257
+ </button>
258
+ </div>
259
+
260
+ {/* inline expansion — the SAME row, under the buttons (k93 idiom) */}
261
+ {mode === "video" && (
262
+ <div style={{ border: "1px solid var(--vi-border)", borderRadius: "0.4rem", padding: "0.5rem" }}>
263
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", alignItems: "center" }}>
264
+ <label className="vi-btn vi-btn-sm vi-btn-accent vi-file-label">
265
+ {uploading ? "Uploading…" : video ? "Change video" : "Upload a video"}
266
+ <input
267
+ type="file"
268
+ accept="video/*"
269
+ className="vi-file-input"
270
+ disabled={busy || uploading}
271
+ onChange={(e) => {
272
+ const file = e.target.files && e.target.files[0];
273
+ if (file) void onPickVideoFile(file);
274
+ e.target.value = "";
275
+ }}
276
+ />
277
+ </label>
278
+ <button
279
+ type="button"
280
+ className="vi-btn vi-btn-sm vi-btn-ghost"
281
+ disabled={busy}
282
+ aria-expanded={showLib}
283
+ onClick={() => setShowLib((v) => !v)}
284
+ >
285
+ {showLib ? "Hide library" : `Pick from library (${libraryVideos.length})`}
286
+ </button>
287
+ {video && (
288
+ <span className="vi-comfy-hint" style={{ opacity: 0.9, display: "inline-flex", alignItems: "center", gap: "0.3rem" }}>
289
+ ▶ {fileName(video)}
290
+ {video.duration_s ? ` · ${video.duration_s.toFixed(1)}s` : ""}
291
+ <button type="button" className="vi-btn vi-btn-sm vi-btn-ghost" disabled={busy}
292
+ onClick={() => setVideo(null)} title="Remove this video" style={{ padding: "0 0.3rem" }}>✕</button>
293
+ </span>
294
+ )}
295
+ </div>
296
+ {showLib && <LibraryVideoList videos={libraryVideos} onPick={(ref) => { setVideo(ref); setShowLib(false); }} />}
297
+ </div>
298
+ )}
299
+
300
+ {mode === "images" && (
301
+ <div style={{ border: "1px solid var(--vi-border)", borderRadius: "0.4rem", padding: "0.5rem" }}>
302
+ {images.length > 0 && (
303
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.4rem", marginBottom: "0.4rem" }}>
304
+ {images.map((ref, idx) => (
305
+ <div key={`${ref.uri}_${idx}`} style={{ position: "relative" }}>
306
+ <img src={mediaBytesUrl(ref.uri)} alt={`photo ${idx + 1}`}
307
+ style={{ width: "3.6rem", height: "3.6rem", objectFit: "cover", borderRadius: "0.35rem", background: "#000", border: "1px solid var(--vi-border)" }} />
308
+ <button type="button" className="vi-btn vi-btn-sm vi-btn-ghost" disabled={busy}
309
+ onClick={() => setImages((prev) => prev.filter((_, i) => i !== idx))}
310
+ title="Remove this photo"
311
+ style={{ position: "absolute", top: -6, right: -6, padding: "0 0.3rem", fontSize: "0.7rem", lineHeight: 1.4, background: "var(--vi-border)", borderRadius: "0.3rem" }}>
312
+ ✕
313
+ </button>
314
+ </div>
315
+ ))}
316
+ </div>
317
+ )}
318
+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", alignItems: "center" }}>
319
+ <label className="vi-btn vi-btn-sm vi-btn-accent vi-file-label">
320
+ {uploading ? "Uploading…" : images.length ? "Add photos" : "Upload photos"}
321
+ <input
322
+ type="file"
323
+ accept="image/*"
324
+ multiple
325
+ className="vi-file-input"
326
+ disabled={busy || uploading || images.length >= MAX_SOURCE_IMAGES}
327
+ onChange={(e) => {
328
+ if (e.target.files && e.target.files.length > 0) void onPickImageFiles(e.target.files);
329
+ e.target.value = "";
330
+ }}
331
+ />
332
+ </label>
333
+ <button
334
+ type="button"
335
+ className="vi-btn vi-btn-sm vi-btn-ghost"
336
+ disabled={busy || images.length >= MAX_SOURCE_IMAGES}
337
+ aria-expanded={showLib}
338
+ onClick={() => setShowLib((v) => !v)}
339
+ >
340
+ {showLib ? "Hide library" : `Pick from library (${libraryImages.length})`}
341
+ </button>
342
+ <span className="vi-comfy-hint" style={{ opacity: 0.7 }}>{images.length}/{MAX_SOURCE_IMAGES}</span>
343
+ </div>
344
+ {showLib && images.length < MAX_SOURCE_IMAGES && (
345
+ <LibraryImageGrid images={libraryImages} onPick={addImage} />
346
+ )}
347
+ </div>
348
+ )}
349
+
350
+ {pickError && (
351
+ <p className="vi-error" role="alert" style={{ margin: 0 }}>{pickError}</p>
352
+ )}
353
+
354
+ {/* ONE button + the progress line */}
355
+ <div className="vi-comfy-bar-actions vi-run-bar" style={{ flexWrap: "wrap", alignItems: "center" }}>
356
+ <button
357
+ type="button"
358
+ className="vi-btn vi-btn-accent"
359
+ disabled={!canCreate}
360
+ onClick={() => void create()}
361
+ title={
362
+ !name.trim()
363
+ ? "Give the identity a name"
364
+ : !hasSource
365
+ ? "Add a video or a few photos"
366
+ : "Build the identity: reference views + 3D model"
367
+ }
368
+ >
369
+ {busy ? "Creating…" : "Create identity"}
370
+ </button>
371
+ {progress && (
372
+ <span
373
+ className={job.view.phase === "error" ? "vi-error" : "vi-comfy-hint"}
374
+ role="status"
375
+ style={{ opacity: 0.9, fontVariantNumeric: "tabular-nums", overflow: "hidden", textOverflow: "ellipsis" }}
376
+ title={job.view.logLine ?? undefined}
377
+ >
378
+ {job.view.phase === "done" && createdCount > 0
379
+ ? `done · ${createdCount} identit${createdCount === 1 ? "y" : "ies"}: ${job.view.identities
380
+ .map((i) => `${i.slug}${i.glb ? " ✓" : i.error ? " (views only)" : ""}`)
381
+ .join(", ")}`
382
+ : progress}
383
+ </span>
384
+ )}
385
+ </div>
386
+
387
+ {advanced && <AdvancedExpander open={advancedOpen}>{advanced}</AdvancedExpander>}
388
+ </section>
389
+ );
390
+ }
@@ -50,6 +50,7 @@ import { request, okValue, errorOf, describeAppError } from "../../transport/cli
50
50
  import { hugpyConfig, mediaBytesUrl } from "../../config";
51
51
  import { addToLibrary, useMediaLibrary } from "../../video/mediaLibrary";
52
52
  import { UnnamedWarnPanel, WarnUnnamedToggle, useWarnUnnamedPref, deriveNameFrom } from "../../video/UnnamedWarn";
53
+ import { LengthRow } from "../../video/LengthRow";
53
54
  import { enqueueResultSchema, mediaRefSchema, uploadResultSchema } from "../../video/contract";
54
55
  import type { MediaRef } from "../../video/contract";
55
56
  import { DropReceptacle } from "../../video/DropReceptacle";
@@ -2596,23 +2597,15 @@ export function StudioGenerateSurface({
2596
2597
  <input id="vi-clipcard-height" className="vi-knob-input" type="number" min={64} step={8}
2597
2598
  value={height} onChange={(e) => setHeight(Number(e.target.value) || 0)} />
2598
2599
  </div>
2599
- <div className="vi-knob" style={{ maxWidth: "6rem" }}>
2600
- <label htmlFor="vi-clipcard-fps">fps</label>
2601
- <input id="vi-clipcard-fps" className="vi-knob-input" type="number" min={1}
2602
- value={fps} onChange={(e) => setFps(Number(e.target.value) || 0)} />
2603
- </div>
2604
- <div className="vi-knob" style={{ maxWidth: "11rem" }}>
2605
- <label htmlFor="vi-clipcard-frames">length — frames</label>
2606
- <input id="vi-clipcard-frames" className="vi-knob-input" type="number" min={1}
2607
- value={requestedFrames} placeholder="model default (81)"
2608
- onChange={(e) => setRequestedFrames(e.target.value)} />
2609
- <span className="vi-knob-hint">
2610
- {requestedFrames.trim() !== "" && Number(requestedFrames) > 0 && fps > 0
2611
- ? `\u2248 ${(Number(requestedFrames) / fps).toFixed(1)}s @ ${fps}fps`
2612
- : `blank = model default \u2014 81f \u2248 ${fps > 0 ? (81 / fps).toFixed(1) : "?"}s @ ${fps}fps`}
2613
- {" \u00b7 clamped to the model ceiling, snapped to 4k+1"}
2614
- </span>
2615
- </div>
2600
+ {/* LENGTH ROW (operator 2026-08-13): fps | frames | seconds — any two
2601
+ set the third; the dimmed one is derived, last-touched wins. */}
2602
+ <LengthRow
2603
+ idPrefix="vi-clipcard-len"
2604
+ fps={fps}
2605
+ onFps={(n) => setFps(n)}
2606
+ frames={requestedFrames}
2607
+ onFrames={setRequestedFrames}
2608
+ />
2616
2609
  <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2617
2610
  <label htmlFor="vi-clipcard-steps">steps</label>
2618
2611
  <input id="vi-clipcard-steps" className="vi-knob-input" type="number" min={1} max={100}