@hugpy/video-intelligence-ui 0.1.0 → 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 (108) 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 +1031 -364
  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 +60 -12
  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 +162 -6
  86. package/src/stations/studio/StudioMovieComposer.tsx +763 -318
  87. package/src/stations/studio/{StudioMovieComposer.tsx.bak-sessions-20260812 → StudioMovieComposer.tsx.bak-declutter-20260813} +350 -48
  88. package/src/stations/studio/StudioPlane.tsx +35 -3
  89. package/src/stations/studio/StudioViewer.tsx +116 -14
  90. package/src/stations/studio/coordinationBadge.tsx +206 -0
  91. package/src/stations/studio/identityUpload.ts +66 -0
  92. package/src/stations/studio/promptCard.tsx +362 -59
  93. package/src/stations/studio/useIdentityCreateJob.ts +184 -0
  94. package/src/stations/studio/useIdentityProfiles.ts +66 -0
  95. package/src/stations/useScriptFirstRun.ts +596 -0
  96. package/src/style/app.css +176 -0
  97. package/src/video/LengthRow.tsx +189 -0
  98. package/src/video/LibraryGroups.tsx +81 -0
  99. package/src/video/UnnamedWarn.tsx +146 -0
  100. package/src/video/coordinationReport.ts +266 -0
  101. package/src/video/jobClock.ts +104 -0
  102. package/src/video/spreadAssist.ts +26 -0
  103. package/src/video/useMediaJobs.ts +26 -0
  104. package/src/video/useOperatorHistory.ts +105 -0
  105. package/src/video/useToks.ts +193 -0
  106. package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +0 -228
  107. package/src/stations/GenerateStation.tsx.bak-tester-removal +0 -5170
  108. package/src/stations/studio/StudioMovieSessions.tsx.bak-sessions-20260812 +0 -417
@@ -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
+ }
@@ -49,6 +49,8 @@ import { createPortal } from "react-dom";
49
49
  import { request, okValue, errorOf, describeAppError } from "../../transport/client";
50
50
  import { hugpyConfig, mediaBytesUrl } from "../../config";
51
51
  import { addToLibrary, useMediaLibrary } from "../../video/mediaLibrary";
52
+ import { UnnamedWarnPanel, WarnUnnamedToggle, useWarnUnnamedPref, deriveNameFrom } from "../../video/UnnamedWarn";
53
+ import { LengthRow } from "../../video/LengthRow";
52
54
  import { enqueueResultSchema, mediaRefSchema, uploadResultSchema } from "../../video/contract";
53
55
  import type { MediaRef } from "../../video/contract";
54
56
  import { DropReceptacle } from "../../video/DropReceptacle";
@@ -85,7 +87,7 @@ import { IdentityProfileControls, type SelectedProfile } from "./IdentityProfile
85
87
  // The SHARED prompt-card system (k88/k89): the Clip surface's single prompt card
86
88
  // rides the same shell + [Prompt][Negative] tabs the Scene/Movie/Cinema lists use,
87
89
  // plus the standard-negative composition helper and the sidebar About expander.
88
- import { AboutExpander, PromptCard, PromptFieldTabs, composeNegative } from "./promptCard";
90
+ import { AboutExpander, PromptCard, PromptCardSettings, PromptFieldTabs, composeNegative } from "./promptCard";
89
91
  // ── COMPATIBILITY AWARENESS (operator ruling 2026-07-29) ───────────────────────────
90
92
  // The fleet's MEASURED render table (GET /video/render/presets) + the pure derivations
91
93
  // over it. Everything this surface offers — capabilities, model pins, geometry, the
@@ -304,7 +306,7 @@ export function StudioGenerateSurface({
304
306
  // k89: the card's [standard negative] tick — default OFF to preserve the Clip
305
307
  // surface's blank-negative default; when on, the standard artifact/junk set is
306
308
  // COMPOSED into the payload at submit (composeNegative — the wire is unchanged).
307
- const [stdNegative, setStdNegative] = useSessionState("studio.clip.stdNegative", false);
309
+ const [stdNegative, setStdNegative] = useSessionState("studio.clip.stdNegative", true);
308
310
  // k89: the bottom "[▸ Tester]" collapsible that now hosts the cross-model sweep
309
311
  // (the run row's ghost 🧪 button is retired).
310
312
  const [clipTesterOpen, setClipTesterOpen] = useState(false);
@@ -317,6 +319,20 @@ export function StudioGenerateSurface({
317
319
  // `project` (auto-archive NAME); blank = auto-named (the default). The list of
318
320
  // existing names backs the Settings datalist; free-typing a new one creates it.
319
321
  const [project, setProject] = useSessionState("studio.clip.project", "");
322
+ // EXPLICIT CLIP LENGTH (operator 2026-08-13: "the length of the video
323
+ // generations has been ambiguous") — requested_frames, blank = the bound
324
+ // model's default (81 for real models). The spine clamps to the model
325
+ // ceiling and snaps to the 4k+1 temporal cadence.
326
+ const [requestedFrames, setRequestedFrames] = useSessionState("studio.clip.frames", "");
327
+ // UNNAMED WARNING (operator 2026-08-13): explicit per-section option,
328
+ // default OFF, localStorage-remembered. The clip's NAME is its `project`
329
+ // (the auto-archive name every generate route accepts).
330
+ const [warnUnnamed, setWarnUnnamed] = useWarnUnnamedPref("clip");
331
+ const [nameWarn, setNameWarn] = useState(false);
332
+ const [pendingRun, setPendingRun] = useState(false);
333
+ // Auto-derived project names may be replaced by the next ✨ Generate; a
334
+ // human-typed project is never overwritten.
335
+ const autoProjectRef = useRef("");
320
336
 
321
337
  // i2v start image (cap 1) — the `start_image` field.
322
338
  const [startImages, setStartImages] = useSessionState<MediaRef[]>(
@@ -1111,7 +1127,20 @@ export function StudioGenerateSurface({
1111
1127
  const wouldBindSynthetic =
1112
1128
  (isT2V || isI2V) && bindsSyntheticTier(vramBudget, realFloorGb);
1113
1129
 
1114
- const onGenerate = useCallback(async () => {
1130
+ // Post-commit re-run after the warn panel names the clip: state has settled,
1131
+ // this effect's closure is fresh, so the submit sees the new project name.
1132
+ useEffect(() => {
1133
+ if (!pendingRun) return;
1134
+ setPendingRun(false);
1135
+ void onGenerate(true);
1136
+ });
1137
+
1138
+ const onGenerate = useCallback(async (skipNameCheck = false) => {
1139
+ if (!skipNameCheck && warnUnnamed && project.trim() === "") {
1140
+ setNameWarn(true);
1141
+ return;
1142
+ }
1143
+ setNameWarn(false);
1115
1144
  if (blockSubmit) {
1116
1145
  setGenMsg(
1117
1146
  v2vBadGeom
@@ -1125,6 +1154,11 @@ export function StudioGenerateSurface({
1125
1154
  setGenMsg("VRAM budget must be a positive number (or blank for the route default).");
1126
1155
  return;
1127
1156
  }
1157
+ const framesNum = requestedFrames.trim() === "" ? undefined : Number(requestedFrames);
1158
+ if (framesNum !== undefined && (!Number.isInteger(framesNum) || framesNum < 1)) {
1159
+ setGenMsg("Length must be a whole number of frames \u2265 1 (or blank for the model default).");
1160
+ return;
1161
+ }
1128
1162
  const stepsNum = steps.trim() === "" ? undefined : Number(steps);
1129
1163
  if (
1130
1164
  stepsNum !== undefined &&
@@ -1152,6 +1186,7 @@ export function StudioGenerateSurface({
1152
1186
  vram_budget_gb: budgetNum,
1153
1187
  steps: stepsNum,
1154
1188
  cfg: cfgNum,
1189
+ requested_frames: requestedFrames.trim() !== "" ? Number(requestedFrames) : undefined,
1155
1190
  model_id: modelId.trim() ? modelId.trim() : undefined,
1156
1191
  // Optional auto-archive NAME (choose-or-type). Blank = auto-named (default).
1157
1192
  project: project.trim() ? project.trim() : undefined,
@@ -1250,8 +1285,7 @@ export function StudioGenerateSurface({
1250
1285
  pendingSubjectStage,
1251
1286
  onEnqueued,
1252
1287
  project,
1253
- refreshProjects,
1254
- ]);
1288
+ refreshProjects,, warnUnnamed, project]);
1255
1289
 
1256
1290
  const synthPresets = presets.filter((p) => isSyntheticPreset(p));
1257
1291
  const realPresets = presets.filter((p) => !isSyntheticPreset(p));
@@ -2431,7 +2465,32 @@ export function StudioGenerateSurface({
2431
2465
  error={assist.assistError}
2432
2466
  onDismissError={assist.dismissError}
2433
2467
  onEnhance={() => void assist.runAssist("detail", prompt, setPrompt)}
2434
- onGenerate={() => void assist.runAssist("generate", prompt, setPrompt)}
2468
+ onGenerate={() =>
2469
+ void (async () => {
2470
+ let fresh = "";
2471
+ await assist.runAssist("generate", prompt, (p) => {
2472
+ fresh = p;
2473
+ setPrompt(p);
2474
+ });
2475
+ if (!fresh.trim()) return;
2476
+ // NEGATIVE RIDES ALONG (operator 2026-08-13): generated
2477
+ // exclusions APPEND to any typed negative; std tick untouched.
2478
+ await assist.runNegative({ subject: fresh }, (neg) => {
2479
+ const cur = negative.trim().replace(/,\s*$/, "");
2480
+ setNegative(cur ? `${cur}, ${neg}` : neg);
2481
+ });
2482
+ // NAME RIDES ALONG: derive the project name from the fresh
2483
+ // prompt when blank or itself auto-derived.
2484
+ const t = project.trim();
2485
+ if (t === "" || t === autoProjectRef.current) {
2486
+ const derived = deriveNameFrom(fresh);
2487
+ if (derived) {
2488
+ autoProjectRef.current = derived;
2489
+ setProject(derived);
2490
+ }
2491
+ }
2492
+ })()
2493
+ }
2435
2494
  canEnhance={prompt.trim() !== ""}
2436
2495
  models={assist.assistModels}
2437
2496
  model={assist.assistModel}
@@ -2488,6 +2547,92 @@ export function StudioGenerateSurface({
2488
2547
  ))}
2489
2548
  </div>
2490
2549
  )}
2550
+
2551
+ {/* SETTINGS EXPANDER (operator ask 2026-08-13): the clip prompt
2552
+ component hosts the FULL knob set, same as Scene/Movie/Cinema
2553
+ prompt components. Clip is a SINGLE-prompt surface, so these
2554
+ bind the SAME state as the left-column Settings tab — change a
2555
+ knob in either place and both update; comprehensive by
2556
+ construction (model pin included). The TEMPLATE chooser stays
2557
+ on the Settings tab only: it morphs the form shape (required
2558
+ inputs), it is not a render knob. */}
2559
+ <PromptCardSettings label="settings — model · size · steps · cfg · seed · budget">
2560
+ <div className="vi-knob vi-studio-knob-wide" style={{ minWidth: "16rem" }}>
2561
+ <label htmlFor="vi-clipcard-model">model pin</label>
2562
+ <select
2563
+ id="vi-clipcard-model"
2564
+ className="vi-knob-select"
2565
+ value={modelId}
2566
+ onChange={(e) => setModelId(e.target.value)}
2567
+ >
2568
+ <option value="">Auto — router picks by budget + resolution</option>
2569
+ {capModels.length > 0 && (
2570
+ <optgroup label={`Ratified for ${CAP_LABELS[capability]}`}>
2571
+ {capModels.map((id) => (
2572
+ <option key={id} value={id}>
2573
+ {id}
2574
+ </option>
2575
+ ))}
2576
+ </optgroup>
2577
+ )}
2578
+ {(showAllModels || !renderLoaded || modelOffMenu) && (
2579
+ <optgroup label={renderLoaded ? "Other studio models — unverified for this capability" : "Studio models"}>
2580
+ {STUDIO_MODEL_IDS.filter((id) => !capModels.includes(id)).map((id) => (
2581
+ <option key={id} value={id}>
2582
+ {id}
2583
+ {renderLoaded ? " · unverified" : ""}
2584
+ </option>
2585
+ ))}
2586
+ </optgroup>
2587
+ )}
2588
+ </select>
2589
+ </div>
2590
+ <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2591
+ <label htmlFor="vi-clipcard-width">width</label>
2592
+ <input id="vi-clipcard-width" className="vi-knob-input" type="number" min={64} step={8}
2593
+ value={width} onChange={(e) => setWidth(Number(e.target.value) || 0)} />
2594
+ </div>
2595
+ <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2596
+ <label htmlFor="vi-clipcard-height">height</label>
2597
+ <input id="vi-clipcard-height" className="vi-knob-input" type="number" min={64} step={8}
2598
+ value={height} onChange={(e) => setHeight(Number(e.target.value) || 0)} />
2599
+ </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
+ />
2609
+ <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2610
+ <label htmlFor="vi-clipcard-steps">steps</label>
2611
+ <input id="vi-clipcard-steps" className="vi-knob-input" type="number" min={1} max={100}
2612
+ value={steps} placeholder="model default" onChange={(e) => setSteps(e.target.value)} />
2613
+ </div>
2614
+ <div className="vi-knob" style={{ maxWidth: "7rem" }}>
2615
+ <label htmlFor="vi-clipcard-cfg">cfg</label>
2616
+ <input id="vi-clipcard-cfg" className="vi-knob-input" type="number" min={0} max={20} step={0.5}
2617
+ value={cfg} placeholder="model default" onChange={(e) => setCfg(e.target.value)} />
2618
+ </div>
2619
+ <div className="vi-knob" style={{ maxWidth: "8rem" }}>
2620
+ <label htmlFor="vi-clipcard-seed">seed</label>
2621
+ <input id="vi-clipcard-seed" className="vi-knob-input" type="number" min={0}
2622
+ value={seed} onChange={(e) => setSeed(Number(e.target.value) || 0)} />
2623
+ </div>
2624
+ <div className="vi-knob" style={{ maxWidth: "9rem" }}>
2625
+ <label htmlFor="vi-clipcard-vram">VRAM budget GB</label>
2626
+ <input id="vi-clipcard-vram" className="vi-knob-input" type="number" min={1}
2627
+ value={vramBudget} placeholder="autofit" onChange={(e) => setVramBudget(e.target.value)} />
2628
+ </div>
2629
+ <div className="vi-knob" style={{ maxWidth: "12rem" }}>
2630
+ <label htmlFor="vi-clipcard-project">project (archive name)</label>
2631
+ <input id="vi-clipcard-project" className="vi-knob-input" type="text"
2632
+ value={project} placeholder="optional" onChange={(e) => setProject(e.target.value)} />
2633
+ <WarnUnnamedToggle checked={warnUnnamed} onChange={setWarnUnnamed} />
2634
+ </div>
2635
+ </PromptCardSettings>
2491
2636
  </PromptCard>
2492
2637
  </ol>
2493
2638
  </section>
@@ -2609,6 +2754,17 @@ export function StudioGenerateSurface({
2609
2754
  </p>
2610
2755
  )}
2611
2756
  </div>
2757
+ {nameWarn && (
2758
+ <UnnamedWarnPanel
2759
+ noun="clip"
2760
+ onProceed={(name) => {
2761
+ setNameWarn(false);
2762
+ if (name) setProject(name);
2763
+ setPendingRun(true);
2764
+ }}
2765
+ onDismiss={() => setNameWarn(false)}
2766
+ />
2767
+ )}
2612
2768
  <div className="vi-station-footer-run">
2613
2769
  <button
2614
2770
  type="button"