@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.
- 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 +901 -366
- 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 +6 -3
- 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 +10 -17
- package/src/stations/studio/StudioMovieComposer.tsx +296 -59
- 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 +157 -0
- package/src/video/LengthRow.tsx +189 -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 +5 -0
- 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
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
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}
|