@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.
- package/package.json +1 -3
- package/src/config.ts +36 -0
- package/src/demo/assets/generate/cinema-cavern/movie.mp4 +0 -0
- package/src/demo/assets/generate/cinema-cavern/seg_00.mp4 +0 -0
- package/src/demo/assets/generate/clips/cat-lounge.mp4 +0 -0
- package/src/demo/assets/generate/clips/drone-city.mp4 +0 -0
- package/src/demo/assets/generate/clips/paper-sails.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/movie.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_00.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_01.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_02.mp4 +0 -0
- package/src/demo/assets/generate/movie-galaxy/seg_03.mp4 +0 -0
- package/src/demo/assets/generate/movie-medow/movie.mp4 +0 -0
- package/src/demo/assets/generate/movie-medow/seg_00.mp4 +0 -0
- package/src/demo/assets/generate/movie-medow/seg_01.mp4 +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00000.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00001.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00002.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00003.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00004.png +0 -0
- package/src/demo/assets/generate/scene-desert/frame_00005.png +0 -0
- package/src/demo/assets/generate/scene-desert/scene.mp4 +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00000.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00001.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00002.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00003.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00004.png +0 -0
- package/src/demo/assets/generate/scene-vintage/frame_00005.png +0 -0
- package/src/demo/assets/generate/scene-vintage/scene.mp4 +0 -0
- package/src/demo/assets/generate2/s0.png +0 -0
- package/src/demo/assets/generate2/s1.png +0 -0
- package/src/demo/assets/generate2/s10.png +0 -0
- package/src/demo/assets/generate2/s11.png +0 -0
- package/src/demo/assets/generate2/s12.png +0 -0
- package/src/demo/assets/generate2/s13.png +0 -0
- package/src/demo/assets/generate2/s14.png +0 -0
- package/src/demo/assets/generate2/s15.png +0 -0
- package/src/demo/assets/generate2/s16.png +0 -0
- package/src/demo/assets/generate2/s17.png +0 -0
- package/src/demo/assets/generate2/s18.png +0 -0
- package/src/demo/assets/generate2/s19.png +0 -0
- package/src/demo/assets/generate2/s2.png +0 -0
- package/src/demo/assets/generate2/s20.png +0 -0
- package/src/demo/assets/generate2/s21.mp4 +0 -0
- package/src/demo/assets/generate2/s22.mp4 +0 -0
- package/src/demo/assets/generate2/s23.mp4 +0 -0
- package/src/demo/assets/generate2/s24.mp4 +0 -0
- package/src/demo/assets/generate2/s25.mp4 +0 -0
- package/src/demo/assets/generate2/s26.mp4 +0 -0
- package/src/demo/assets/generate2/s27.mp4 +0 -0
- package/src/demo/assets/generate2/s28.mp4 +0 -0
- package/src/demo/assets/generate2/s29.mp4 +0 -0
- package/src/demo/assets/generate2/s3.png +0 -0
- package/src/demo/assets/generate2/s30.mp4 +0 -0
- package/src/demo/assets/generate2/s4.png +0 -0
- package/src/demo/assets/generate2/s5.png +0 -0
- package/src/demo/assets/generate2/s6.png +0 -0
- package/src/demo/assets/generate2/s7.png +0 -0
- package/src/demo/assets/generate2/s8.png +0 -0
- package/src/demo/assets/generate2/s9.png +0 -0
- package/src/demo/demoFetch.ts +126 -3
- package/src/demo/fixtures.ts +72 -4
- package/src/demo/promptAssist.ts +25 -0
- package/src/demo/sampleData.ts +960 -0
- package/src/demo/sampleSets.ts +172 -0
- package/src/stations/ActiveProcessesStation.tsx +18 -3
- package/src/stations/GenerateStation.tsx +1031 -364
- package/src/stations/GoalComposer.tsx +9 -1
- package/src/stations/ScriptFirstStation.tsx +1081 -0
- package/src/stations/ToksPanel.tsx +188 -0
- package/src/stations/WorkbenchDrawer.tsx +4 -3
- package/src/stations/WorkbenchSidebar.tsx +60 -12
- package/src/stations/oracle/DagPanel.tsx +391 -0
- package/src/stations/oracle/OracleStation.tsx +304 -0
- package/src/stations/oracle/SpatialTab.tsx +185 -0
- package/src/stations/oracle/StewardPanel.tsx +165 -0
- package/src/stations/oracle/oracle.css +467 -0
- package/src/stations/oracle/oracleShared.tsx +160 -0
- package/src/stations/oracle/oracleTypes.ts +304 -0
- package/src/stations/oracle/useOracle.ts +560 -0
- package/src/stations/registry.ts +37 -0
- package/src/stations/scriptFirst.css +350 -0
- package/src/stations/studio/IdentitiesStation.tsx +219 -292
- package/src/stations/studio/IdentityCreatePanel.tsx +390 -0
- package/src/stations/studio/StudioGenerateTab.tsx +162 -6
- package/src/stations/studio/StudioMovieComposer.tsx +763 -318
- package/src/stations/studio/{StudioMovieComposer.tsx.bak-sessions-20260812 → StudioMovieComposer.tsx.bak-declutter-20260813} +350 -48
- package/src/stations/studio/StudioPlane.tsx +35 -3
- package/src/stations/studio/StudioViewer.tsx +116 -14
- package/src/stations/studio/coordinationBadge.tsx +206 -0
- package/src/stations/studio/identityUpload.ts +66 -0
- package/src/stations/studio/promptCard.tsx +362 -59
- package/src/stations/studio/useIdentityCreateJob.ts +184 -0
- package/src/stations/studio/useIdentityProfiles.ts +66 -0
- package/src/stations/useScriptFirstRun.ts +596 -0
- package/src/style/app.css +176 -0
- package/src/video/LengthRow.tsx +189 -0
- package/src/video/LibraryGroups.tsx +81 -0
- package/src/video/UnnamedWarn.tsx +146 -0
- package/src/video/coordinationReport.ts +266 -0
- package/src/video/jobClock.ts +104 -0
- package/src/video/spreadAssist.ts +26 -0
- package/src/video/useMediaJobs.ts +26 -0
- package/src/video/useOperatorHistory.ts +105 -0
- package/src/video/useToks.ts +193 -0
- package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +0 -228
- package/src/stations/GenerateStation.tsx.bak-tester-removal +0 -5170
- 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",
|
|
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
|
-
|
|
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={() =>
|
|
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"
|