@hugpy/video-intelligence-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +41 -0
- package/assets/sampels/images/bycycle/bd8603bad35d4f179f4d1134d8faf2ee.mp4 +0 -0
- package/assets/sampels/images/bycycle/frame_00000.png +0 -0
- package/assets/sampels/images/bycycle/frame_00001.png +0 -0
- package/assets/sampels/images/bycycle/frame_00002.png +0 -0
- package/assets/sampels/images/bycycle/frame_00003.png +0 -0
- package/assets/sampels/images/bycycle/frame_00004.png +0 -0
- package/assets/sampels/images/bycycle/frame_00005.png +0 -0
- package/assets/sampels/images/forrest/ff23090611b844a6b85c224f388bd542.mp4 +0 -0
- package/assets/sampels/images/forrest/frame_00000.png +0 -0
- package/assets/sampels/images/forrest/frame_00001.png +0 -0
- package/assets/sampels/images/forrest/frame_00002.png +0 -0
- package/assets/sampels/images/forrest/frame_00003.png +0 -0
- package/assets/sampels/images/forrest/frame_00004.png +0 -0
- package/assets/sampels/images/forrest/frame_00005.png +0 -0
- package/assets/sampels/images/shenzo/e1a4fcd623084ab4a986d11c8cd00db8.mp4 +0 -0
- package/assets/sampels/images/shenzo/frame_00000.png +0 -0
- package/assets/sampels/images/shenzo/frame_00001.png +0 -0
- package/assets/sampels/images/shenzo/frame_00002.png +0 -0
- package/assets/sampels/images/shenzo/frame_00003.png +0 -0
- package/assets/sampels/images/shenzo/frame_00004.png +0 -0
- package/assets/sampels/images/shenzo/frame_00005.png +0 -0
- package/assets/sampels/scenes/man.mp4 +0 -0
- package/assets/sampels/scenes/woman.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/_assembly/contrib_00.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/movie.json +67 -0
- package/assets/sampels/studio_movies/caverns/movie.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/manifest.json +87 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/provenance.json +7 -0
- package/assets/sampels/studio_movies/caverns/segment_01/branch.png +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/concat_list.txt +2 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_00.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_01.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/movie.json +81 -0
- package/assets/sampels/studio_movies/keeper-matrix/movie.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/manifest.json +87 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/provenance.json +7 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/branch.png +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/manifest.json +87 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/provenance.json +7 -0
- package/assets/sampels/videos/a_red_paper_boat_drifting_on_a_calm_pond__gentle_ripples__mo_a62d6c28.mp4 +0 -0
- package/assets/sampels/videos/aurora.mp4 +0 -0
- package/assets/sampels/videos/dark_night.mp4 +0 -0
- package/assets/sampels/videos/fire_and_soul.mp4 +0 -0
- package/assets/sampels/videos/harsh_seas.mp4 +0 -0
- package/entry.tsx +74 -0
- package/index.html +53 -0
- package/package.json +42 -0
- package/src/assets/hugpy-mark.png +0 -0
- package/src/config.ts +676 -0
- package/src/demo/DemoBanner.tsx +54 -0
- package/src/demo/assets/demo-audio.mp3 +0 -0
- package/src/demo/assets/demo-clip.mp4 +0 -0
- package/src/demo/assets/demo-crop.mp4 +0 -0
- package/src/demo/assets/demo-poster.jpg +0 -0
- package/src/demo/assets/frame-1.jpg +0 -0
- package/src/demo/assets/frame-2.jpg +0 -0
- package/src/demo/assets/frame-3.jpg +0 -0
- package/src/demo/assets/frame-4.jpg +0 -0
- package/src/demo/assets/frame-5.jpg +0 -0
- package/src/demo/assets/frame-6.jpg +0 -0
- package/src/demo/assets/gen-1.jpg +0 -0
- package/src/demo/assets/gen-2.jpg +0 -0
- package/src/demo/assets.d.ts +14 -0
- package/src/demo/demo.css +64 -0
- package/src/demo/demoFetch.ts +305 -0
- package/src/demo/fixtures.ts +814 -0
- package/src/demo/index.ts +43 -0
- package/src/demo/mode.ts +81 -0
- package/src/demo/promptAssist.ts +181 -0
- package/src/demo/seed.ts +97 -0
- package/src/nav/BrandMark.css +66 -0
- package/src/nav/BrandMark.tsx +40 -0
- package/src/nav/Navbar.css +13 -0
- package/src/nav/Navbar.tsx +68 -0
- package/src/regions/SpatialRegionEditor.tsx +375 -0
- package/src/regions/TemporalRegionEditor.tsx +485 -0
- package/src/regions/types.ts +85 -0
- package/src/session.ts +85 -0
- package/src/share.ts +77 -0
- package/src/stations/ActivePanel.tsx +55 -0
- package/src/stations/ActiveProcessesStation.tsx +272 -0
- package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +228 -0
- package/src/stations/AddFileBar.tsx +59 -0
- package/src/stations/AudioCropStation.tsx +157 -0
- package/src/stations/ComfyPanel.tsx +210 -0
- package/src/stations/ComfyStation.tsx +196 -0
- package/src/stations/CondensedKnobStrip.tsx +245 -0
- package/src/stations/ConsolePanel.tsx +82 -0
- package/src/stations/FrameExtractStation.tsx +148 -0
- package/src/stations/GenIdentityBar.tsx +412 -0
- package/src/stations/GenerateStation.tsx +5603 -0
- package/src/stations/GenerateStation.tsx.bak-tester-removal +5170 -0
- package/src/stations/GoalComposer.tsx +139 -0
- package/src/stations/ImageCropStation.tsx +153 -0
- package/src/stations/KnobInfo.tsx +64 -0
- package/src/stations/PlaceholderStation.tsx +26 -0
- package/src/stations/SectionTabs.tsx +276 -0
- package/src/stations/SharePanel.tsx +298 -0
- package/src/stations/SidebarRegistry.tsx +105 -0
- package/src/stations/StationShell.tsx +96 -0
- package/src/stations/StudioClipsStation.tsx +28 -0
- package/src/stations/WorkbenchDrawer.tsx +66 -0
- package/src/stations/WorkbenchSidebar.tsx +1224 -0
- package/src/stations/WorkbenchStation.tsx +91 -0
- package/src/stations/comfyEmbed.ts +79 -0
- package/src/stations/drawerShell.tsx +419 -0
- package/src/stations/ops/AudioCropCore.tsx +218 -0
- package/src/stations/ops/CharacterGroupsPanel.tsx +716 -0
- package/src/stations/ops/FrameExtractCore.tsx +695 -0
- package/src/stations/ops/ImageCropCore.tsx +164 -0
- package/src/stations/ops/VideoCropCore.tsx +193 -0
- package/src/stations/ops/useVideoCropJobs.ts +224 -0
- package/src/stations/pollCaps.ts +87 -0
- package/src/stations/registry.ts +191 -0
- package/src/stations/studio/AngleRingEditor.tsx +159 -0
- package/src/stations/studio/IdentitiesStation.tsx +2455 -0
- package/src/stations/studio/IdentityProfileControls.tsx +262 -0
- package/src/stations/studio/StudioActiveProcesses.tsx +224 -0
- package/src/stations/studio/StudioAssistLogPanel.tsx +220 -0
- package/src/stations/studio/StudioGenerateMode.tsx +29 -0
- package/src/stations/studio/StudioGenerateTab.tsx +2688 -0
- package/src/stations/studio/StudioItemCard.tsx +334 -0
- package/src/stations/studio/StudioLibraryTab.tsx +506 -0
- package/src/stations/studio/StudioMovieComposer.tsx +2875 -0
- package/src/stations/studio/StudioMovieComposer.tsx.bak-sessions-20260812 +2729 -0
- package/src/stations/studio/StudioMovieSessions.tsx +553 -0
- package/src/stations/studio/StudioMovieSessions.tsx.bak-sessions-20260812 +417 -0
- package/src/stations/studio/StudioPlane.tsx +302 -0
- package/src/stations/studio/StudioSplicedRow.tsx +476 -0
- package/src/stations/studio/StudioViewer.tsx +339 -0
- package/src/stations/studio/TurntableViewer.tsx +220 -0
- package/src/stations/studio/movieTimeline.ts +198 -0
- package/src/stations/studio/promptCard.tsx +498 -0
- package/src/stations/studio/studioItem.ts +87 -0
- package/src/stations/studio/studioShared.tsx +821 -0
- package/src/stations/studio/useIdentityProfiles.ts +575 -0
- package/src/stations/studio/useReconstruction.ts +283 -0
- package/src/stations/types.ts +43 -0
- package/src/stations/useAudioCropJobs.ts +202 -0
- package/src/stations/useAudioExtractJob.ts +144 -0
- package/src/stations/useCropJobs.ts +206 -0
- package/src/stations/useFrameJobs.ts +140 -0
- package/src/stations/useGenerateJob.ts +151 -0
- package/src/stations/useGenerateMovieJob.ts +177 -0
- package/src/stations/useGenerateRowJobs.ts +184 -0
- package/src/stations/useGenerateSceneJob.ts +156 -0
- package/src/stations/useGenerateSceneRowJobs.ts +282 -0
- package/src/style/app.css +4974 -0
- package/src/transport/client.ts +262 -0
- package/src/transport/telemetry.ts +97 -0
- package/src/video/DropReceptacle.tsx +122 -0
- package/src/video/PromptAssistButtons.tsx +118 -0
- package/src/video/composerBridge.ts +66 -0
- package/src/video/contract.ts +599 -0
- package/src/video/jobTracker.ts +702 -0
- package/src/video/mediaLibrary.ts +328 -0
- package/src/video/renderCompat.ts +512 -0
- package/src/video/sessionForm.ts +189 -0
- package/src/video/spreadAssist.ts +392 -0
- package/src/video/studioBridge.ts +82 -0
- package/src/video/useMediaJobs.ts +279 -0
- package/src/video/useModels.ts +413 -0
- package/src/video/useMoviePresets.ts +97 -0
- package/src/video/useMovieSessions.ts +292 -0
- package/src/video/usePresets.ts +167 -0
- package/src/video/useProjects.ts +87 -0
- package/src/video/usePromptAssist.ts +394 -0
- package/src/video/useRenderPresets.ts +221 -0
- package/src/video/useStudioAssistLog.ts +266 -0
- package/src/video/useStudioPresets.ts +128 -0
- package/src/video/useWorkerCalls.ts +165 -0
- package/tsconfig.json +17 -0
- package/vite.config.ts +33 -0
|
@@ -0,0 +1,2688 @@
|
|
|
1
|
+
// The Studio GENERATE SURFACE — ONE source of truth for the studio generate CONTROLS
|
|
2
|
+
// (preset tiers + morphing param panel + hero inputs + Generate). It is the TOP of
|
|
3
|
+
// the single Studio plane (StudioPlane), which renders it above the inline clip
|
|
4
|
+
// viewer + list; that one plane is mounted in both places the studio appears — the
|
|
5
|
+
// standalone Studio station (StudioClipsStation) and the Generate station's "studio"
|
|
6
|
+
// mode (StudioGenerateMode). It is a pure CONTROLLED component — the source clip + its
|
|
7
|
+
// staged mode + the clip list arrive as props, so each mount owns that wiring (and the
|
|
8
|
+
// studioBridge registration) while this form stays identical in both. (The file keeps
|
|
9
|
+
// its historical name; the exported component is `StudioGenerateSurface`.)
|
|
10
|
+
//
|
|
11
|
+
// Tier-first: pick a curated preset (grouped REAL tiers vs SYNTHETIC previews), and
|
|
12
|
+
// the form MORPHS into that generator's PREFORMED SHAPE instead of showing one
|
|
13
|
+
// uniform panel:
|
|
14
|
+
//
|
|
15
|
+
// • t2v shape — image inputs are hidden entirely (text-only note);
|
|
16
|
+
// • i2v "needs a start image" shape — the START IMAGE input becomes the dominant
|
|
17
|
+
// element near the top, with the source-clip picker offered as the "or extend a
|
|
18
|
+
// clip" alternative;
|
|
19
|
+
// • v2v (restyle) shape — the SOURCE CLIP picker gets that hero prominence;
|
|
20
|
+
// • id_lock (identity lock) shape — the REFERENCE-IMAGE slot list (1–4, ordered) is
|
|
21
|
+
// the dominant accent-bordered element, with an OPTIONAL composition-control input
|
|
22
|
+
// (a static pose/depth/sketch anchor) subdued below;
|
|
23
|
+
// • fully custom (no preset) — the current full panel, inputs inline + optional.
|
|
24
|
+
//
|
|
25
|
+
// Required-vs-optional visual language: a required-and-empty input gets the accent
|
|
26
|
+
// attention treatment and the Generate gate names it; optional inputs stay subdued.
|
|
27
|
+
//
|
|
28
|
+
// Every affordance reuses an EXISTING pattern: presets from useStudioPresets; the
|
|
29
|
+
// image UPLOAD path is the Generate station's upload→ingest→guard-kind flow
|
|
30
|
+
// (POST /uploads → POST /video/ingest → MediaRef) and the hero drop surface is the
|
|
31
|
+
// shared DropReceptacle; the LIBRARY pick reads useMediaLibrary; the source-clip pick
|
|
32
|
+
// reads the plane's clip list; "Send to Studio" / "use as source" staging arrives via
|
|
33
|
+
// studioBridge as the `source` prop; the enqueue body uses the exact route field names,
|
|
34
|
+
// dropped by JSON.stringify when unset.
|
|
35
|
+
//
|
|
36
|
+
// FIELDS ARE DISTINCT: `start_image` (i2v, exactly 1 — the first frame) and
|
|
37
|
+
// `reference_images` (id_lock, 1–4 ORDERED — identity to lock; the hash keys on order)
|
|
38
|
+
// are separate route fields with separate slot lists; `control_image`+`control_kind`
|
|
39
|
+
// are id_lock-only and both-or-neither.
|
|
40
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
41
|
+
// Sub-tab input memory — see the block of useSessionState calls in
|
|
42
|
+
// StudioGenerateSurface and the rationale in video/sessionForm.ts.
|
|
43
|
+
import {
|
|
44
|
+
nextSessionSeq,
|
|
45
|
+
useSessionRef,
|
|
46
|
+
useSessionState,
|
|
47
|
+
} from "../../video/sessionForm";
|
|
48
|
+
import { createPortal } from "react-dom";
|
|
49
|
+
import { request, okValue, errorOf, describeAppError } from "../../transport/client";
|
|
50
|
+
import { hugpyConfig, mediaBytesUrl } from "../../config";
|
|
51
|
+
import { addToLibrary, useMediaLibrary } from "../../video/mediaLibrary";
|
|
52
|
+
import { enqueueResultSchema, mediaRefSchema, uploadResultSchema } from "../../video/contract";
|
|
53
|
+
import type { MediaRef } from "../../video/contract";
|
|
54
|
+
import { DropReceptacle } from "../../video/DropReceptacle";
|
|
55
|
+
import type { GoalComposerAttach, GoalComposerAttached } from "../GoalComposer";
|
|
56
|
+
import { usePromptAssist } from "../../video/usePromptAssist";
|
|
57
|
+
import { PromptAssistButtons } from "../../video/PromptAssistButtons";
|
|
58
|
+
import { getSessionId } from "../../session";
|
|
59
|
+
import { isCanned } from "../../demo/mode";
|
|
60
|
+
import { DEMO_STUDIO_PROMPT } from "../../demo/seed";
|
|
61
|
+
import type { StudioPreset } from "../../video/useStudioPresets";
|
|
62
|
+
import { useProjects } from "../../video/useProjects";
|
|
63
|
+
import type { StudioMode } from "../../video/studioBridge";
|
|
64
|
+
import {
|
|
65
|
+
VACE_W,
|
|
66
|
+
VACE_H,
|
|
67
|
+
VACE_FPS,
|
|
68
|
+
VACE_BUDGET_GB,
|
|
69
|
+
STUDIO_MODEL_IDS,
|
|
70
|
+
STUDIO_REAL_FLOOR_GB,
|
|
71
|
+
STUDIO_SAFE_FILL_GB,
|
|
72
|
+
bindsSyntheticTier,
|
|
73
|
+
isSyntheticPreset,
|
|
74
|
+
shortId,
|
|
75
|
+
clipToMediaRef,
|
|
76
|
+
LibraryImageGrid,
|
|
77
|
+
type Clip,
|
|
78
|
+
} from "./studioShared";
|
|
79
|
+
// B2: the Movie authoring surface the mode switch renders. Self-contained (owns its
|
|
80
|
+
// own goal state, enqueue + poll, and playback) — see StudioMovieComposer.tsx.
|
|
81
|
+
import { StudioMovieComposer } from "./StudioMovieComposer";
|
|
82
|
+
// IDENTITY PROFILES (stage a): the shared "Identity" control block (From profile /
|
|
83
|
+
// Save as identity profile) — one additive row in the reference area below.
|
|
84
|
+
import { IdentityProfileControls, type SelectedProfile } from "./IdentityProfileControls";
|
|
85
|
+
// The SHARED prompt-card system (k88/k89): the Clip surface's single prompt card
|
|
86
|
+
// rides the same shell + [Prompt][Negative] tabs the Scene/Movie/Cinema lists use,
|
|
87
|
+
// plus the standard-negative composition helper and the sidebar About expander.
|
|
88
|
+
import { AboutExpander, PromptCard, PromptFieldTabs, composeNegative } from "./promptCard";
|
|
89
|
+
// ── COMPATIBILITY AWARENESS (operator ruling 2026-07-29) ───────────────────────────
|
|
90
|
+
// The fleet's MEASURED render table (GET /video/render/presets) + the pure derivations
|
|
91
|
+
// over it. Everything this surface offers — capabilities, model pins, geometry, the
|
|
92
|
+
// budget floor, the proven badges — is now filtered by what the fleet can actually
|
|
93
|
+
// render and by what the user has already attached, instead of by hand-maintained
|
|
94
|
+
// mirrors of backend facts. Courtesy layer only: the route's capability gate stays
|
|
95
|
+
// authoritative, and every consumer below falls back to its prior list while
|
|
96
|
+
// `renderLoaded` is false, so a slow discovery GET can never empty a picker.
|
|
97
|
+
import { useRenderPresets } from "../../video/useRenderPresets";
|
|
98
|
+
import {
|
|
99
|
+
CLIP_CAPABILITIES,
|
|
100
|
+
MOVIE_CAPABILITY,
|
|
101
|
+
capabilityOffers,
|
|
102
|
+
capabilityForAttachments,
|
|
103
|
+
capabilityLabel,
|
|
104
|
+
acceptedSlots,
|
|
105
|
+
geometryForCapability,
|
|
106
|
+
withinGeometry,
|
|
107
|
+
modelsForCapability,
|
|
108
|
+
suggestedBudgetGb,
|
|
109
|
+
minBudgetGbForCapability,
|
|
110
|
+
provenForCapability,
|
|
111
|
+
fillIfEmpty,
|
|
112
|
+
type Attachments,
|
|
113
|
+
} from "../../video/renderCompat";
|
|
114
|
+
|
|
115
|
+
// `motion` joined the union on 2026-07-29: it is a SERVABLE capability (video_routes
|
|
116
|
+
// widened the control_image gate from id_lock-only on 2026-07-27, making the VACE
|
|
117
|
+
// structural-control branch reachable) and this surface already owns the only input it
|
|
118
|
+
// needs — the pose/depth/sketch control still. It was simply never offered.
|
|
119
|
+
type Capability = "t2v" | "i2v" | "v2v" | "id_lock" | "motion";
|
|
120
|
+
type ControlKind = "" | "pose" | "depth" | "sketch";
|
|
121
|
+
type UploadPhase = "idle" | "uploading" | "ingesting";
|
|
122
|
+
/** Where an ingested image slots (a single upload/library flow feeds all three). */
|
|
123
|
+
type ImageTarget = "start" | "reference" | "control";
|
|
124
|
+
|
|
125
|
+
// i2v `start_image` is a SINGLE first frame (spine takes one). id_lock `reference_images`
|
|
126
|
+
// is a DIFFERENT field: 1–4 ordered stills the render locks identity to (order is
|
|
127
|
+
// semantic — the content hash keys on it), max 4 enforced by the route.
|
|
128
|
+
const MAX_START_IMAGES = 1;
|
|
129
|
+
const MAX_REFERENCE_IMAGES = 4;
|
|
130
|
+
|
|
131
|
+
// Stable per-slot key for the reorderable reference list (order matters, and the same
|
|
132
|
+
// still may legitimately appear twice — so key by slot identity, not by uri).
|
|
133
|
+
// The counter is SESSION-persisted now that the reference list itself survives a
|
|
134
|
+
// reload: a module `let` would restart at 0 and mint `ref_1` a second time,
|
|
135
|
+
// colliding with a restored slot.
|
|
136
|
+
function refKey(): string {
|
|
137
|
+
return `ref_${nextSessionSeq("studio.clip.refKeySeq")}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface StudioGenerateSurfaceProps {
|
|
141
|
+
presets: StudioPreset[];
|
|
142
|
+
presetsLoading: boolean;
|
|
143
|
+
/** The host's durable clip list — the source-clip picker chooses from it. */
|
|
144
|
+
clips: Clip[];
|
|
145
|
+
/** Host-owned staged/picked source clip (Send to Studio / use-as-source). */
|
|
146
|
+
source: MediaRef | null;
|
|
147
|
+
setSource: (m: MediaRef | null) => void;
|
|
148
|
+
/** Consume mode from the last external stage (drives capability on handoff). */
|
|
149
|
+
sourceMode: StudioMode;
|
|
150
|
+
/** Bumps on each external stage so the capability handoff runs exactly once. */
|
|
151
|
+
stageSeq: number;
|
|
152
|
+
/** Quiet-refresh the clip list after an enqueue — the new job then appears in the
|
|
153
|
+
* inline clip list below (same plane), no navigation. */
|
|
154
|
+
onEnqueued: () => void;
|
|
155
|
+
/** Round 8: the DOM host for the left-sidebar "Settings" tab. When present, the knob
|
|
156
|
+
* grid renders THERE (via a studio-local portal) instead of the center — folding the
|
|
157
|
+
* former inline knob column into the sidebar Settings tab (movie-shape canonical). Null
|
|
158
|
+
* until the sidebar mounts its Settings panel; the knob state lives here regardless. */
|
|
159
|
+
settingsHost: HTMLElement | null;
|
|
160
|
+
/** When PROVIDED, the top-level Generate tab (Clip / Cinema) owns the clip-vs-movie
|
|
161
|
+
* choice, so this surface is LOCKED to that value and the internal Clip|Movie switcher
|
|
162
|
+
* is hidden. When ABSENT (the standalone /studio-clips station mount), the surface keeps
|
|
163
|
+
* its own internal switcher + useState. "movie" is the Cinema surface (label only — the
|
|
164
|
+
* wire/tester value stays "movie"). */
|
|
165
|
+
lockedSurfaceMode?: "clip" | "movie";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Labels now come from renderCompat (ONE table both studio surfaces read) so a
|
|
169
|
+
// capability can never be named two things in two pickers. Kept as a local alias so the
|
|
170
|
+
// existing call sites read unchanged.
|
|
171
|
+
const CAP_LABELS: Record<Capability, string> = {
|
|
172
|
+
t2v: capabilityLabel("t2v"),
|
|
173
|
+
i2v: capabilityLabel("i2v"),
|
|
174
|
+
v2v: capabilityLabel("v2v"),
|
|
175
|
+
id_lock: capabilityLabel("id_lock"),
|
|
176
|
+
motion: capabilityLabel("motion"),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// A compact source-clip poster that plays inline ON DEMAND — the studio-local twin of
|
|
180
|
+
// the Session Library's <RefThumb>, factored so the SELECTED-source summary and every
|
|
181
|
+
// picker row share ONE poster/preview renderer (no duplicated <video> markup). The
|
|
182
|
+
// still is a muted, metadata-only <video> seeked to an early frame; /video/media is
|
|
183
|
+
// Range-aware so only the seeked byte range loads until the viewer presses play.
|
|
184
|
+
// Clicking the poster (or a row's Preview button) flips `playing`, swapping in a
|
|
185
|
+
// <video controls autoPlay> so the clip is previewable IN PLACE. The poster lives in a
|
|
186
|
+
// <button> (it is a non-interactive muted still, so nesting it is valid); the playing
|
|
187
|
+
// state is a bare <div> wrapper (a <video controls> must NEVER sit inside a <button>).
|
|
188
|
+
// EXPORTED (operator ask 2026-07-12): StudioViewer's "▤ Play from library" panel reuses
|
|
189
|
+
// this verbatim for its own poster/id rows, so the viewer's picker and this surface's
|
|
190
|
+
// source-clip picker share one poster implementation instead of a fourth copy.
|
|
191
|
+
export function SourceThumb({
|
|
192
|
+
uri,
|
|
193
|
+
name,
|
|
194
|
+
playing,
|
|
195
|
+
onToggle,
|
|
196
|
+
}: {
|
|
197
|
+
uri: string;
|
|
198
|
+
name: string;
|
|
199
|
+
playing: boolean;
|
|
200
|
+
onToggle: () => void;
|
|
201
|
+
}) {
|
|
202
|
+
const poster = `${mediaBytesUrl(uri)}#t=0.1`;
|
|
203
|
+
if (playing) {
|
|
204
|
+
return (
|
|
205
|
+
<div className="vi-studio-src-thumb vi-studio-src-thumb--playing">
|
|
206
|
+
<video
|
|
207
|
+
src={poster}
|
|
208
|
+
controls
|
|
209
|
+
autoPlay
|
|
210
|
+
playsInline
|
|
211
|
+
preload="metadata"
|
|
212
|
+
aria-label={`${name} preview`}
|
|
213
|
+
/>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return (
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
className="vi-studio-src-thumb vi-studio-src-thumb-btn"
|
|
221
|
+
onClick={onToggle}
|
|
222
|
+
title={`Preview ${name}`}
|
|
223
|
+
aria-label={`Preview ${name}`}
|
|
224
|
+
>
|
|
225
|
+
<video src={poster} muted playsInline preload="metadata" aria-hidden="true" />
|
|
226
|
+
<span className="vi-studio-src-play" aria-hidden="true">
|
|
227
|
+
▶
|
|
228
|
+
</span>
|
|
229
|
+
</button>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function StudioGenerateSurface({
|
|
234
|
+
presets,
|
|
235
|
+
presetsLoading,
|
|
236
|
+
clips,
|
|
237
|
+
source,
|
|
238
|
+
setSource,
|
|
239
|
+
sourceMode,
|
|
240
|
+
stageSeq,
|
|
241
|
+
onEnqueued,
|
|
242
|
+
settingsHost,
|
|
243
|
+
lockedSurfaceMode,
|
|
244
|
+
}: StudioGenerateSurfaceProps) {
|
|
245
|
+
// ── SUB-TAB INPUT MEMORY (operator ask 2026-08-06) ──────────────────────────
|
|
246
|
+
// Every OPERATOR-AUTHORED field below is `useSessionState` rather than
|
|
247
|
+
// `useState`, so it survives this surface being unmounted — which happens on
|
|
248
|
+
// every Clip↔Cinema switch (the two surfaces swap), every Scene/Movie↔Clip
|
|
249
|
+
// switch (StudioGenerateMode unmounts), and every workbench tab switch
|
|
250
|
+
// (WorkbenchStation renders one station body). Same tuple, same updaters; the
|
|
251
|
+
// store is session-scoped and per-tab. See video/sessionForm.ts for why a store
|
|
252
|
+
// beats keep-mounted-but-hidden here. Transient UI below (pickers, previews,
|
|
253
|
+
// upload phases, busy/error flags, in-flight job ids) deliberately stays on
|
|
254
|
+
// plain useState — restoring a stale "uploading…" would be its own bug.
|
|
255
|
+
//
|
|
256
|
+
// B2 mode switch — Clip (this existing surface, default) | Movie/Cinema (StudioMovieComposer).
|
|
257
|
+
// When the top-level Generate tab locks the choice (Clip / Cinema tabs), `lockedSurfaceMode`
|
|
258
|
+
// is the effective value and the internal switcher below is hidden; the standalone
|
|
259
|
+
// /studio-clips station passes nothing, so its own internal state + switcher drive it.
|
|
260
|
+
const [internalSurfaceMode, setInternalSurfaceMode] = useSessionState<"clip" | "movie">(
|
|
261
|
+
"studio.clip.surfaceMode",
|
|
262
|
+
"clip",
|
|
263
|
+
);
|
|
264
|
+
const surfaceMode = lockedSurfaceMode ?? internalSurfaceMode;
|
|
265
|
+
|
|
266
|
+
const [capability, setCapability] = useSessionState<Capability>(
|
|
267
|
+
"studio.clip.capability",
|
|
268
|
+
"i2v",
|
|
269
|
+
);
|
|
270
|
+
const [presetId, setPresetId] = useSessionState("studio.clip.presetId", "");
|
|
271
|
+
|
|
272
|
+
const [width, setWidth] = useSessionState("studio.clip.width", 512);
|
|
273
|
+
const [height, setHeight] = useSessionState("studio.clip.height", 512);
|
|
274
|
+
const [fps, setFps] = useSessionState("studio.clip.fps", 24);
|
|
275
|
+
const [seed, setSeed] = useSessionState("studio.clip.seed", 0);
|
|
276
|
+
// Text fields (blank = "unset"): the route/model fills the default. Kept as
|
|
277
|
+
// strings so "empty" is distinct from 0.
|
|
278
|
+
const [vramBudget, setVramBudget] = useSessionState("studio.clip.vramBudget", "");
|
|
279
|
+
// MERGE-NOT-OVERWRITE bookkeeping: true once the OPERATOR has typed in the budget
|
|
280
|
+
// field. A preset/capability change fills a budget the user never touched (it is a
|
|
281
|
+
// workflow slot) but never overwrites a number they chose. One ref, not state — it
|
|
282
|
+
// only ever gates a write, so it must not cause a render of its own. SESSION-scoped
|
|
283
|
+
// for the same reason the values are: the autofit effect below runs on every mount,
|
|
284
|
+
// so a mount that restored a hand-typed budget but a fresh `false` here would
|
|
285
|
+
// immediately overwrite it.
|
|
286
|
+
const budgetTouched = useSessionRef("studio.clip.budgetTouched", false);
|
|
287
|
+
// Same discipline for GEOMETRY. The snap below is a workflow slot and fires on every
|
|
288
|
+
// capability change and once when the measured table lands — which would otherwise
|
|
289
|
+
// overwrite a width the operator typed in the first moments of the page. v2v is
|
|
290
|
+
// deliberately exempt: its inputs are disabled (a hard router-backed lock), so there is
|
|
291
|
+
// nothing there for a user to have touched.
|
|
292
|
+
const geomTouched = useSessionRef("studio.clip.geomTouched", false);
|
|
293
|
+
const [steps, setSteps] = useSessionState("studio.clip.steps", "");
|
|
294
|
+
const [cfg, setCfg] = useSessionState("studio.clip.cfg", "");
|
|
295
|
+
const [modelId, setModelId] = useSessionState("studio.clip.modelId", "");
|
|
296
|
+
// Showroom flavor (?demo=1 only): open with a cinematic example prompt (matches
|
|
297
|
+
// the sunset sample gens) so the brochure's Studio surface tells a coherent story
|
|
298
|
+
// out of the box. Seed lives in src/demo/ and is injected ONLY behind isCanned() —
|
|
299
|
+
// the live path stays an empty prompt.
|
|
300
|
+
const [prompt, setPrompt] = useSessionState("studio.clip.prompt", () =>
|
|
301
|
+
isCanned() ? DEMO_STUDIO_PROMPT : "",
|
|
302
|
+
);
|
|
303
|
+
const [negative, setNegative] = useSessionState("studio.clip.negative", "");
|
|
304
|
+
// k89: the card's [standard negative] tick — default OFF to preserve the Clip
|
|
305
|
+
// surface's blank-negative default; when on, the standard artifact/junk set is
|
|
306
|
+
// COMPOSED into the payload at submit (composeNegative — the wire is unchanged).
|
|
307
|
+
const [stdNegative, setStdNegative] = useSessionState("studio.clip.stdNegative", false);
|
|
308
|
+
// k89: the bottom "[▸ Tester]" collapsible that now hosts the cross-model sweep
|
|
309
|
+
// (the run row's ghost 🧪 button is retired).
|
|
310
|
+
const [clipTesterOpen, setClipTesterOpen] = useState(false);
|
|
311
|
+
// LLM PROMPT-ASSIST (Enhance / Generate) — the same shared hook + button cluster the
|
|
312
|
+
// Generate station uses (POSTs promptAssistUrl). Here the draft is the single clip
|
|
313
|
+
// prompt and apply replaces it wholesale. context.kind = "scene" (a clip is a short
|
|
314
|
+
// motion sequence, so the assistant leans into camera/motion phrasing).
|
|
315
|
+
const assist = usePromptAssist({ kind: "scene", specKey: "studio" });
|
|
316
|
+
// Optional human PROJECT name (choose-or-type). Threaded into the enqueue as
|
|
317
|
+
// `project` (auto-archive NAME); blank = auto-named (the default). The list of
|
|
318
|
+
// existing names backs the Settings datalist; free-typing a new one creates it.
|
|
319
|
+
const [project, setProject] = useSessionState("studio.clip.project", "");
|
|
320
|
+
|
|
321
|
+
// i2v start image (cap 1) — the `start_image` field.
|
|
322
|
+
const [startImages, setStartImages] = useSessionState<MediaRef[]>(
|
|
323
|
+
"studio.clip.startImages",
|
|
324
|
+
[],
|
|
325
|
+
);
|
|
326
|
+
// id_lock reference images (1–4, ordered) — the `reference_images` field.
|
|
327
|
+
// Only the server MediaRef travels (never pixels), so this is as cheap to keep
|
|
328
|
+
// in session storage as the media library's own entries.
|
|
329
|
+
const [referenceImages, setReferenceImages] = useSessionState<
|
|
330
|
+
Array<{ key: string; ref: MediaRef }>
|
|
331
|
+
>("studio.clip.referenceImages", []);
|
|
332
|
+
// IDENTITY PROFILES (stage a): when the reference set came from a SAVED profile,
|
|
333
|
+
// this holds it — the enqueue then sends `identity_profile:<slug>` (canonical) and
|
|
334
|
+
// omits raw reference_images. ANY ad-hoc edit (upload/library-pick/remove/reorder)
|
|
335
|
+
// clears it back to null: the set is then an "unsaved identity" sent as raw paths.
|
|
336
|
+
const [selectedProfile, setSelectedProfile] = useSessionState<SelectedProfile | null>(
|
|
337
|
+
"studio.clip.selectedProfile",
|
|
338
|
+
null,
|
|
339
|
+
);
|
|
340
|
+
// id_lock optional composition control — `control_image` + `control_kind` (both/neither).
|
|
341
|
+
const [controlImage, setControlImage] = useSessionState<MediaRef | null>(
|
|
342
|
+
"studio.clip.controlImage",
|
|
343
|
+
null,
|
|
344
|
+
);
|
|
345
|
+
const [controlKind, setControlKind] = useSessionState<ControlKind>(
|
|
346
|
+
"studio.clip.controlKind",
|
|
347
|
+
"",
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
const [imagePhase, setImagePhase] = useState<UploadPhase>("idle");
|
|
351
|
+
const [imageError, setImageError] = useState<string | null>(null);
|
|
352
|
+
const imageBusy = imagePhase === "uploading" || imagePhase === "ingesting";
|
|
353
|
+
|
|
354
|
+
// ── Subject-clip switcher (operator ask 2026-07-12) ──
|
|
355
|
+
// "Generate new clip" is a two-step flow: pendingSubjectStage marks the INTENT
|
|
356
|
+
// (source cleared, capability forced to t2v, note shown) between the click and the
|
|
357
|
+
// next successful enqueue; awaitingSubjectJobId then names the in-flight job whose
|
|
358
|
+
// completion (read off the `clips` poll the viewer/library already share) auto-stages
|
|
359
|
+
// its clip as the new subject. Only one is ever truthy at a time.
|
|
360
|
+
const [pendingSubjectStage, setPendingSubjectStage] = useState(false);
|
|
361
|
+
const [awaitingSubjectJobId, setAwaitingSubjectJobId] = useState<string | null>(null);
|
|
362
|
+
|
|
363
|
+
// Advanced escape hatch for the model pin: off by default so the menu is the ratified
|
|
364
|
+
// set, on when an operator deliberately wants every studio model id (labelled
|
|
365
|
+
// unverified for the active capability, never presented as equivalent).
|
|
366
|
+
const [showAllModels, setShowAllModels] = useState(false);
|
|
367
|
+
|
|
368
|
+
const [showLibPicker, setShowLibPicker] = useState(false); // start/reference (one active per shape)
|
|
369
|
+
const [showControlLib, setShowControlLib] = useState(false);
|
|
370
|
+
const [showSourcePicker, setShowSourcePicker] = useState(false);
|
|
371
|
+
// Which picker row is previewing inline (job_id), and whether the SELECTED-source
|
|
372
|
+
// summary poster is previewing. Only one renderSource variant mounts at a time (hero
|
|
373
|
+
// is a single value), so these component-level flags are never shared across two
|
|
374
|
+
// visible pickers. Cleared on select/clear so a stale row never keeps auto-playing.
|
|
375
|
+
const [previewId, setPreviewId] = useState<string | null>(null);
|
|
376
|
+
const [sourcePreview, setSourcePreview] = useState(false);
|
|
377
|
+
|
|
378
|
+
const [genBusy, setGenBusy] = useState(false);
|
|
379
|
+
const [genMsg, setGenMsg] = useState<string | null>(null);
|
|
380
|
+
|
|
381
|
+
// STUDIO TESTER (operator 2026-08-05) — fan the CURRENT prompt across every
|
|
382
|
+
// servable model of this surface's type (clip/movie) via POST studioTesterUrl.
|
|
383
|
+
// Operator-gated + background job; per-model results stream to the generation
|
|
384
|
+
// log directly below this surface (StudioAssistLogPanel). One prompt in, a
|
|
385
|
+
// battery out — no per-model clicking.
|
|
386
|
+
const [testerBusy, setTesterBusy] = useState(false);
|
|
387
|
+
// The FILTERED sweep (operator 2026-08-05): each surface computes the model
|
|
388
|
+
// subset that fits its current capability + geometry (see clipTesterModels /
|
|
389
|
+
// movieTesterModels below) and passes it as `models`. An EMPTY subset means the
|
|
390
|
+
// measured render table has not landed yet (or the current geometry sits outside
|
|
391
|
+
// every ratified envelope) — we omit `models` so the backend sweeps the whole
|
|
392
|
+
// type roster, the same courtesy-layer fallback every picker here uses when
|
|
393
|
+
// `renderLoaded` is false. A dead button would be a worse failure than a wider
|
|
394
|
+
// sweep.
|
|
395
|
+
const onTestAllModels = useCallback(
|
|
396
|
+
async (opts: {
|
|
397
|
+
category: string;
|
|
398
|
+
models: string[];
|
|
399
|
+
width?: number;
|
|
400
|
+
height?: number;
|
|
401
|
+
fps?: number;
|
|
402
|
+
seed?: number;
|
|
403
|
+
}) => {
|
|
404
|
+
const p = prompt.trim();
|
|
405
|
+
if (!p) {
|
|
406
|
+
setGenMsg("Enter a prompt to test across every model of this type.");
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
setTesterBusy(true);
|
|
410
|
+
setGenMsg(null);
|
|
411
|
+
try {
|
|
412
|
+
const body: Record<string, unknown> = { category: opts.category, prompt: p };
|
|
413
|
+
if (opts.width != null) body.width = opts.width;
|
|
414
|
+
if (opts.height != null) body.height = opts.height;
|
|
415
|
+
if (opts.fps != null) body.fps = opts.fps;
|
|
416
|
+
if (opts.seed != null) body.seed = opts.seed;
|
|
417
|
+
if (opts.models.length > 0) body.models = opts.models;
|
|
418
|
+
const res = await request<unknown>(hugpyConfig.studioTesterUrl, {
|
|
419
|
+
method: "POST",
|
|
420
|
+
headers: { "Content-Type": "application/json" },
|
|
421
|
+
body: JSON.stringify(body),
|
|
422
|
+
meta: { specKey: "studio", operation: "studio.tester.enqueue" },
|
|
423
|
+
});
|
|
424
|
+
if (!mounted.current) return;
|
|
425
|
+
if (!res.ok) {
|
|
426
|
+
setGenMsg(`Tester failed: ${describeAppError(errorOf(res))}`);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
setGenMsg(
|
|
430
|
+
opts.models.length > 0
|
|
431
|
+
? `Testing this prompt across ${opts.models.length} ${opts.category} ` +
|
|
432
|
+
`model${opts.models.length === 1 ? "" : "s"} that fit the current filter — ` +
|
|
433
|
+
`each result appears in the log below as it finishes.`
|
|
434
|
+
: `Testing this prompt across every ${opts.category} model — each model's ` +
|
|
435
|
+
`result appears in the log below as it finishes.`,
|
|
436
|
+
);
|
|
437
|
+
} finally {
|
|
438
|
+
if (mounted.current) setTesterBusy(false);
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
[prompt],
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
const mounted = useRef(true);
|
|
445
|
+
useEffect(() => {
|
|
446
|
+
mounted.current = true;
|
|
447
|
+
return () => {
|
|
448
|
+
mounted.current = false;
|
|
449
|
+
};
|
|
450
|
+
}, []);
|
|
451
|
+
|
|
452
|
+
const library = useMediaLibrary();
|
|
453
|
+
const libraryImages = library.filter((it) => it.ref.kind === "image");
|
|
454
|
+
// Existing project names for the Settings "Project" combobox (choose-or-type).
|
|
455
|
+
const { projects: knownProjects, refresh: refreshProjects } = useProjects(true);
|
|
456
|
+
|
|
457
|
+
const preset = presets.find((p) => p.id === presetId) ?? null;
|
|
458
|
+
const isT2V = capability === "t2v";
|
|
459
|
+
const isI2V = capability === "i2v";
|
|
460
|
+
const isV2V = capability === "v2v";
|
|
461
|
+
const isIdLock = capability === "id_lock";
|
|
462
|
+
const isMotion = capability === "motion";
|
|
463
|
+
|
|
464
|
+
// ── the fleet's measured render table + everything derived from it ───────────────
|
|
465
|
+
// One session-cached GET shared with the Movie composer (module singleton in the
|
|
466
|
+
// hook). `renderLoaded` gates EVERY filter below: while it is false each picker shows
|
|
467
|
+
// its full prior list, so a slow or failed discovery call degrades to exactly the
|
|
468
|
+
// behavior this surface had before, never to an empty menu.
|
|
469
|
+
const {
|
|
470
|
+
presets: renderPresets,
|
|
471
|
+
unavailable: renderUnavailable,
|
|
472
|
+
loaded: renderLoaded,
|
|
473
|
+
renderBox,
|
|
474
|
+
} = useRenderPresets(true);
|
|
475
|
+
|
|
476
|
+
// What the user has attached RIGHT NOW — the input side of the compatibility matrix.
|
|
477
|
+
// Every picker below narrows against this, which is the operator's "every input the
|
|
478
|
+
// user makes filters the options subsequently offered".
|
|
479
|
+
const attached: Attachments = {
|
|
480
|
+
startImage: startImages.length > 0,
|
|
481
|
+
sourceVideo: !!source,
|
|
482
|
+
referenceImages: referenceImages.length > 0,
|
|
483
|
+
controlImage: !!controlImage,
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
// The capability menu, as data: servable-on-this-fleet AND compatible-with-what-is-
|
|
487
|
+
// attached, each unavailable row carrying the backend's OWN refusal wording for its
|
|
488
|
+
// tooltip. Not a gate — an incompatible capability renders DISABLED with the reason,
|
|
489
|
+
// because a user who attached a start image and cannot find restyle is worse off than
|
|
490
|
+
// one who can see why it is greyed out.
|
|
491
|
+
const capOffers = capabilityOffers(
|
|
492
|
+
renderPresets,
|
|
493
|
+
renderUnavailable,
|
|
494
|
+
attached,
|
|
495
|
+
CLIP_CAPABILITIES,
|
|
496
|
+
);
|
|
497
|
+
const offerFor = (c: string) => capOffers.find((o) => o.capability === c);
|
|
498
|
+
|
|
499
|
+
// The ratified geometry envelope for the active capability (832x480@16 on every video
|
|
500
|
+
// row today). Drives the snap-on-pick below and the out-of-envelope flag — the same
|
|
501
|
+
// footgun guard v2v always had, now derived for every capability instead of hardcoded
|
|
502
|
+
// for one.
|
|
503
|
+
const capGeometry = renderLoaded ? geometryForCapability(renderPresets, capability) : null;
|
|
504
|
+
const geomOutOfEnvelope =
|
|
505
|
+
renderLoaded && !withinGeometry(renderPresets, capability, width, height);
|
|
506
|
+
|
|
507
|
+
// The MINIMUM budget that can bind a real model for this capability, read off the
|
|
508
|
+
// preset table's `vram_envelope_gb` (the exact number the router compares a budget
|
|
509
|
+
// against). Replaces the hand-mirrored STUDIO_REAL_FLOOR_GB, which had drifted to 6 —
|
|
510
|
+
// below every real row in the table (cheapest is 8.2).
|
|
511
|
+
const capFloorGb = renderLoaded ? minBudgetGbForCapability(renderPresets, capability) : null;
|
|
512
|
+
const capSuggestGb = renderLoaded ? suggestedBudgetGb(renderPresets, capability) : null;
|
|
513
|
+
|
|
514
|
+
// Model pins that stay inside the ratified set for this capability. The escape hatch
|
|
515
|
+
// below keeps every studio model id reachable — an operator may still pin off-menu,
|
|
516
|
+
// and the option says so rather than pretending the pin is verified.
|
|
517
|
+
const capModels = renderLoaded ? modelsForCapability(renderPresets, capability) : [];
|
|
518
|
+
const modelOffMenu =
|
|
519
|
+
renderLoaded && modelId.trim() !== "" && !capModels.includes(modelId.trim());
|
|
520
|
+
|
|
521
|
+
// STUDIO TESTER rosters (operator 2026-08-05) — the model subset each surface's
|
|
522
|
+
// "Test all models" sweep is scoped to, so a battery only ever runs the models
|
|
523
|
+
// that fit the CURRENT FILTER, not every servable model blindly.
|
|
524
|
+
// CLIP: the ratified models for the ACTIVE capability, kept only while the
|
|
525
|
+
// current width×height is inside that capability's envelope. Geometry is a
|
|
526
|
+
// PER-CAPABILITY envelope in the render table (not per-model), so this is
|
|
527
|
+
// correctly all-or-nothing — out of envelope ⇒ [] ⇒ the button falls back to
|
|
528
|
+
// the whole roster (see onTestAllModels).
|
|
529
|
+
// CINEMA: the movie/assemble capability's ratified models, which via the
|
|
530
|
+
// preset's `composes` already fold in the per-segment t2v/i2v bindings the
|
|
531
|
+
// movie actually renders.
|
|
532
|
+
const clipTesterModels =
|
|
533
|
+
renderLoaded && withinGeometry(renderPresets, capability, width, height)
|
|
534
|
+
? capModels
|
|
535
|
+
: [];
|
|
536
|
+
|
|
537
|
+
// Has this capability ever produced pixels here? Badged, never hidden — `false` is the
|
|
538
|
+
// table telling the truth about itself.
|
|
539
|
+
const capProven = renderLoaded ? provenForCapability(renderPresets, capability) : null;
|
|
540
|
+
|
|
541
|
+
// Which input slots the ACTIVE capability accepts, from the route's own gates.
|
|
542
|
+
const slots = acceptedSlots(capability);
|
|
543
|
+
|
|
544
|
+
// Geometry tooltips, named rather than anonymous: a lock the user cannot explain is a
|
|
545
|
+
// lock they will fight. Both quote the RATIFIED preset the envelope came from.
|
|
546
|
+
const geomLockTitle = capGeometry
|
|
547
|
+
? `Restyle is locked to ${capGeometry.width}×${capGeometry.height} @ ${capGeometry.fps}fps — the envelope “${capGeometry.presetTitle}” is ratified at. The router refuses anything outside it.`
|
|
548
|
+
: `Restyle is locked to ${VACE_W}×${VACE_H} @ ${VACE_FPS}fps (the VACE envelope).`;
|
|
549
|
+
const geomEnvelopeTitle = capGeometry
|
|
550
|
+
? `Ratified envelope for this capability: ${capGeometry.width}×${capGeometry.height} @ ${capGeometry.fps}fps (“${capGeometry.presetTitle}”). Outside it there is no proven binding.`
|
|
551
|
+
: undefined;
|
|
552
|
+
|
|
553
|
+
const addStartImage = useCallback((ref: MediaRef) => {
|
|
554
|
+
setStartImages((prev) => (prev.length >= MAX_START_IMAGES ? prev : [...prev, ref]));
|
|
555
|
+
setShowLibPicker(false);
|
|
556
|
+
}, []);
|
|
557
|
+
const removeStartImage = useCallback((idx: number) => {
|
|
558
|
+
setStartImages((prev) => prev.filter((_, i) => i !== idx));
|
|
559
|
+
}, []);
|
|
560
|
+
|
|
561
|
+
const addReferenceImage = useCallback((ref: MediaRef) => {
|
|
562
|
+
setReferenceImages((prev) =>
|
|
563
|
+
prev.length >= MAX_REFERENCE_IMAGES ? prev : [...prev, { key: refKey(), ref }],
|
|
564
|
+
);
|
|
565
|
+
setShowLibPicker(false);
|
|
566
|
+
setSelectedProfile(null); // an ad-hoc add makes this an UNSAVED identity
|
|
567
|
+
}, []);
|
|
568
|
+
const removeReferenceImage = useCallback((idx: number) => {
|
|
569
|
+
setReferenceImages((prev) => prev.filter((_, i) => i !== idx));
|
|
570
|
+
setSelectedProfile(null);
|
|
571
|
+
}, []);
|
|
572
|
+
const moveReferenceImage = useCallback((idx: number, dir: -1 | 1) => {
|
|
573
|
+
setReferenceImages((prev) => {
|
|
574
|
+
const j = idx + dir;
|
|
575
|
+
if (j < 0 || j >= prev.length) return prev;
|
|
576
|
+
const next = prev.slice();
|
|
577
|
+
const [it] = next.splice(idx, 1);
|
|
578
|
+
next.splice(j, 0, it);
|
|
579
|
+
return next;
|
|
580
|
+
});
|
|
581
|
+
setSelectedProfile(null); // reorder diverges from the profile's canonical order
|
|
582
|
+
}, []);
|
|
583
|
+
|
|
584
|
+
// IDENTITY PROFILES: attach a profile's resolved reference set. A profile pick
|
|
585
|
+
// REPLACES the current set (a profile IS the whole identity); ad-hoc callers append.
|
|
586
|
+
const attachIdentityRefs = useCallback((refs: MediaRef[], replace: boolean) => {
|
|
587
|
+
if (replace) {
|
|
588
|
+
setReferenceImages(refs.slice(0, MAX_REFERENCE_IMAGES).map((ref) => ({ key: refKey(), ref })));
|
|
589
|
+
} else {
|
|
590
|
+
setReferenceImages((prev) => {
|
|
591
|
+
const room = MAX_REFERENCE_IMAGES - prev.length;
|
|
592
|
+
return room <= 0 ? prev : [...prev, ...refs.slice(0, room).map((ref) => ({ key: refKey(), ref }))];
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
setShowLibPicker(false);
|
|
596
|
+
}, []);
|
|
597
|
+
|
|
598
|
+
// Capability handoff: when the shell stages a source (Send to Studio / use as
|
|
599
|
+
// source), adopt its mode — restyle ⇒ v2v, extend ⇒ i2v. Keyed on stageSeq so it
|
|
600
|
+
// runs once per stage, never clobbering a manual capability choice.
|
|
601
|
+
//
|
|
602
|
+
// CROSS-TIER CARRY: an "extend" stage normally lands in i2v, but if the surface is
|
|
603
|
+
// ALREADY holding reference images, land in v2v scene-continuity instead — v2v shows
|
|
604
|
+
// AND sends the carried refs, so chaining scene→scene keeps the identity lock without
|
|
605
|
+
// re-picking (i2v would silently drop the refs). The reference slots themselves are
|
|
606
|
+
// never cleared by a stage, so the lock rides along.
|
|
607
|
+
//
|
|
608
|
+
// 2026-07-29: the landing capability is now VALIDATED against the compatibility
|
|
609
|
+
// matrix instead of assumed. The intent ordering is unchanged (an explicit "restyle"
|
|
610
|
+
// stage means v2v; carried references keep the identity lock alive rather than letting
|
|
611
|
+
// i2v silently drop them), but each candidate is checked with `capabilityForAttachments`
|
|
612
|
+
// so a stage can never land the form on a capability that refuses what is attached.
|
|
613
|
+
useEffect(() => {
|
|
614
|
+
if (stageSeq === 0) return;
|
|
615
|
+
const next: Attachments = {
|
|
616
|
+
startImage: startImages.length > 0,
|
|
617
|
+
sourceVideo: true, // a stage IS a staged source clip
|
|
618
|
+
referenceImages: referenceImages.length > 0,
|
|
619
|
+
controlImage: !!controlImage,
|
|
620
|
+
};
|
|
621
|
+
const intent: Capability =
|
|
622
|
+
sourceMode === "restyle" || referenceImages.length > 0 ? "v2v" : "i2v";
|
|
623
|
+
const resolved = capabilityForAttachments(renderPresets, next, intent);
|
|
624
|
+
setCapability((resolved as Capability) ?? intent);
|
|
625
|
+
setPresetId("");
|
|
626
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
627
|
+
}, [stageSeq]);
|
|
628
|
+
|
|
629
|
+
// FOOTGUN GUARD, GENERALIZED (2026-07-29). This used to be a v2v-only hardcode of the
|
|
630
|
+
// 832x480@16 VACE envelope. It is now derived: whenever the capability changes, the
|
|
631
|
+
// geometry snaps to the envelope the RATIFIED preset for that capability declares, and
|
|
632
|
+
// the budget floor comes from that preset's `vram_envelope_gb`. Every video row on the
|
|
633
|
+
// fleet is 832x480@16 today, so in practice this makes t2v/i2v/id_lock/motion land on
|
|
634
|
+
// the same guaranteed-to-render envelope v2v was already pinned to — which is also the
|
|
635
|
+
// route's own default (video_routes: a 512x512 default "was a GUARANTEED FAIL for two
|
|
636
|
+
// whole capabilities").
|
|
637
|
+
//
|
|
638
|
+
// The v2v HARD LOCK below (disabled inputs + the submit block) is unchanged: the router
|
|
639
|
+
// refuses an out-of-envelope restyle outright, so that one stays a lock rather than a
|
|
640
|
+
// snap. Other capabilities snap once and remain editable, with an honest flag if the
|
|
641
|
+
// operator moves outside the envelope.
|
|
642
|
+
//
|
|
643
|
+
// MERGE DISCIPLINE: the budget is filled only when the operator has not typed one.
|
|
644
|
+
useEffect(() => {
|
|
645
|
+
if (isV2V) {
|
|
646
|
+
setWidth(capGeometry?.width ?? VACE_W);
|
|
647
|
+
setHeight(capGeometry?.height ?? VACE_H);
|
|
648
|
+
setFps(capGeometry?.fps ?? VACE_FPS);
|
|
649
|
+
if (!budgetTouched.current && vramBudget.trim() === "") {
|
|
650
|
+
setVramBudget(String(capSuggestGb ?? VACE_BUDGET_GB));
|
|
651
|
+
}
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (capGeometry && !geomTouched.current) {
|
|
655
|
+
setWidth(capGeometry.width);
|
|
656
|
+
setHeight(capGeometry.height);
|
|
657
|
+
setFps(capGeometry.fps);
|
|
658
|
+
}
|
|
659
|
+
if (!budgetTouched.current && vramBudget.trim() === "" && capSuggestGb != null) {
|
|
660
|
+
setVramBudget(String(capSuggestGb));
|
|
661
|
+
}
|
|
662
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
663
|
+
}, [capability, capGeometry?.presetId, capSuggestGb]);
|
|
664
|
+
|
|
665
|
+
// ── APPLYING A PRESET IS A MERGE, NEVER AN OVERWRITE (operator ruling 2026-07-29) ──
|
|
666
|
+
//
|
|
667
|
+
// A preset supplies the WORKFLOW — capability, geometry, cadence, budget. The user's
|
|
668
|
+
// SESSION INPUTS are theirs: prompt, negative, identity, every attachment, and a seed
|
|
669
|
+
// they chose all survive the apply and are slotted into the workflow around them.
|
|
670
|
+
//
|
|
671
|
+
// WHAT THIS REPLACES, precisely, because it was a data-loss bug and not a preference:
|
|
672
|
+
// the previous body ended `setPrompt(p.prompt ?? ""); setNegative(p.negative ?? "")`.
|
|
673
|
+
// Most studio presets carry no prompt of their own, so picking one BLANKED whatever
|
|
674
|
+
// the operator had just written (and any prompt-assist enhancement they had run on
|
|
675
|
+
// it). It also stamped the preset's seed and budget over hand-typed values. The
|
|
676
|
+
// Generate station's `applyPresetLoad` had the right instinct all along — fill only
|
|
677
|
+
// what the preset actually names — and this is that rule, tightened for text to
|
|
678
|
+
// fill-only-when-empty (`fillIfEmpty`).
|
|
679
|
+
const onPickPreset = useCallback(
|
|
680
|
+
(id: string) => {
|
|
681
|
+
setPresetId(id);
|
|
682
|
+
const p = presets.find((x) => x.id === id) ?? null;
|
|
683
|
+
if (!p) return;
|
|
684
|
+
const cap = (p.capability as Capability) ?? "i2v";
|
|
685
|
+
setCapability(cap);
|
|
686
|
+
// A manual preset pick abandons any armed/in-flight "generate new subject"
|
|
687
|
+
// wait (see the subject-clip switcher below) — the operator has taken over.
|
|
688
|
+
setPendingSubjectStage(false);
|
|
689
|
+
setAwaitingSubjectJobId(null);
|
|
690
|
+
// WORKFLOW SLOTS — the preset owns these. Geometry prefers the RATIFIED envelope
|
|
691
|
+
// for the capability over the curated preset's own numbers when the two disagree,
|
|
692
|
+
// because the ratified table is the one measured against the render box; the
|
|
693
|
+
// curated preset's geometry is intent.
|
|
694
|
+
const geom = renderLoaded ? geometryForCapability(renderPresets, cap) : null;
|
|
695
|
+
// An explicit preset pick IS a request for that workflow's envelope, so it hands
|
|
696
|
+
// geometry back to the presets (a later hand-edit takes it again).
|
|
697
|
+
geomTouched.current = false;
|
|
698
|
+
setWidth(geom?.width ?? p.width);
|
|
699
|
+
setHeight(geom?.height ?? p.height);
|
|
700
|
+
setFps(geom?.fps ?? p.fps);
|
|
701
|
+
// SESSION INPUTS — preserved. A seed the operator moved off 0 is a choice; a seed
|
|
702
|
+
// still at 0 is untouched scaffolding a preset may fill.
|
|
703
|
+
setSeed((cur) => (cur !== 0 ? cur : typeof p.seed === "number" ? p.seed : cur));
|
|
704
|
+
if (!budgetTouched.current) {
|
|
705
|
+
const floor = renderLoaded ? suggestedBudgetGb(renderPresets, cap) : null;
|
|
706
|
+
// Never offer a budget under the capability's real floor — a preset curated
|
|
707
|
+
// before the floor was measured would otherwise silently bind the synthetic tier.
|
|
708
|
+
const wanted =
|
|
709
|
+
typeof p.vram_budget_gb === "number"
|
|
710
|
+
? floor != null
|
|
711
|
+
? Math.max(p.vram_budget_gb, floor)
|
|
712
|
+
: p.vram_budget_gb
|
|
713
|
+
: floor;
|
|
714
|
+
if (wanted != null) setVramBudget(String(wanted));
|
|
715
|
+
}
|
|
716
|
+
// The preset's example prompt is a scaffold for an EMPTY box, never a replacement.
|
|
717
|
+
setPrompt((cur) => fillIfEmpty(cur, p.prompt));
|
|
718
|
+
setNegative((cur) => fillIfEmpty(cur, p.negative));
|
|
719
|
+
},
|
|
720
|
+
[presets, renderPresets, renderLoaded],
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
// Manual capability change drops any picked preset (so it can't fight the choice); the
|
|
724
|
+
// geometry/budget snap for the new capability runs in the derived effect above.
|
|
725
|
+
//
|
|
726
|
+
// 2026-07-29: it now also RE-VALIDATES the user's other inputs against the new
|
|
727
|
+
// capability instead of leaving them to fail at render time. Nothing is deleted — the
|
|
728
|
+
// attachments are the user's — but a model pin that cannot serve the new capability is
|
|
729
|
+
// cleared back to Auto (the router then picks a model that CAN), and an incompatible
|
|
730
|
+
// attachment is named in the callout so the operator can act on it.
|
|
731
|
+
const onPickCapability = useCallback(
|
|
732
|
+
(c: Capability) => {
|
|
733
|
+
setCapability(c);
|
|
734
|
+
setPresetId("");
|
|
735
|
+
// Same abandon-on-manual-override as onPickPreset above.
|
|
736
|
+
setPendingSubjectStage(false);
|
|
737
|
+
setAwaitingSubjectJobId(null);
|
|
738
|
+
if (renderLoaded && modelId.trim() !== "") {
|
|
739
|
+
const ok = modelsForCapability(renderPresets, c);
|
|
740
|
+
if (ok.length > 0 && !ok.includes(modelId.trim())) setModelId("");
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
[renderPresets, renderLoaded, modelId],
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
// ── image upload → ingest → guard kind=image, then route the MediaRef to its slot
|
|
747
|
+
// (start | reference | control). One flow for all three; joins the Session Library. ──
|
|
748
|
+
const onPickImageFile = useCallback(
|
|
749
|
+
async (file: File, target: ImageTarget) => {
|
|
750
|
+
setImageError(null);
|
|
751
|
+
setImagePhase("uploading");
|
|
752
|
+
const fd = new FormData();
|
|
753
|
+
fd.append("file", file);
|
|
754
|
+
fd.append("sid", getSessionId());
|
|
755
|
+
const up = await request<unknown>(hugpyConfig.uploadUrl, {
|
|
756
|
+
method: "POST",
|
|
757
|
+
body: fd,
|
|
758
|
+
meta: { specKey: "studio", operation: "upload" },
|
|
759
|
+
});
|
|
760
|
+
if (!mounted.current) return;
|
|
761
|
+
if (!up.ok) {
|
|
762
|
+
setImageError(describeAppError(errorOf(up)));
|
|
763
|
+
setImagePhase("idle");
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
const upParsed = uploadResultSchema.safeParse(okValue(up));
|
|
767
|
+
if (!upParsed.success) {
|
|
768
|
+
setImageError("Malformed upload response.");
|
|
769
|
+
setImagePhase("idle");
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
setImagePhase("ingesting");
|
|
773
|
+
const ing = await request<unknown>(hugpyConfig.videoIngestUrl, {
|
|
774
|
+
method: "POST",
|
|
775
|
+
body: JSON.stringify({ path: upParsed.data.path }),
|
|
776
|
+
headers: { "Content-Type": "application/json" },
|
|
777
|
+
meta: { specKey: "studio", operation: "ingest" },
|
|
778
|
+
});
|
|
779
|
+
if (!mounted.current) return;
|
|
780
|
+
if (!ing.ok) {
|
|
781
|
+
setImageError(describeAppError(errorOf(ing)));
|
|
782
|
+
setImagePhase("idle");
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
const media = mediaRefSchema.safeParse(okValue(ing));
|
|
786
|
+
if (!media.success) {
|
|
787
|
+
setImageError("Malformed ingest response.");
|
|
788
|
+
setImagePhase("idle");
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (media.data.kind !== "image") {
|
|
792
|
+
setImageError(`Ingested asset is "${media.data.kind}", not an image — pick a still.`);
|
|
793
|
+
setImagePhase("idle");
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
addToLibrary(media.data, "upload", media.data.mime);
|
|
797
|
+
if (target === "start") addStartImage(media.data);
|
|
798
|
+
else if (target === "reference") addReferenceImage(media.data);
|
|
799
|
+
else setControlImage(media.data);
|
|
800
|
+
setImagePhase("idle");
|
|
801
|
+
},
|
|
802
|
+
[addStartImage, addReferenceImage],
|
|
803
|
+
);
|
|
804
|
+
|
|
805
|
+
// ── video upload → ingest → guard kind=video → straight to the staged subject. ──
|
|
806
|
+
// The subject-clip switcher's "Upload video" path (operator ask 2026-07-12): the
|
|
807
|
+
// EXACT mirror of onPickImageFile above (same upload/ingest endpoints, same MediaRef
|
|
808
|
+
// shape) but guarding the OTHER kind this surface consumes, landing straight in
|
|
809
|
+
// `source` instead of a slot list. Shares imagePhase/imageError with the image
|
|
810
|
+
// uploads — one in-flight-upload state machine for every upload this surface offers,
|
|
811
|
+
// so an image upload and a video upload never race each other.
|
|
812
|
+
const onPickVideoFile = useCallback(
|
|
813
|
+
async (file: File) => {
|
|
814
|
+
setImageError(null);
|
|
815
|
+
setImagePhase("uploading");
|
|
816
|
+
const fd = new FormData();
|
|
817
|
+
fd.append("file", file);
|
|
818
|
+
fd.append("sid", getSessionId());
|
|
819
|
+
const up = await request<unknown>(hugpyConfig.uploadUrl, {
|
|
820
|
+
method: "POST",
|
|
821
|
+
body: fd,
|
|
822
|
+
meta: { specKey: "studio", operation: "studio.subject.upload" },
|
|
823
|
+
});
|
|
824
|
+
if (!mounted.current) return;
|
|
825
|
+
if (!up.ok) {
|
|
826
|
+
setImageError(describeAppError(errorOf(up)));
|
|
827
|
+
setImagePhase("idle");
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const upParsed = uploadResultSchema.safeParse(okValue(up));
|
|
831
|
+
if (!upParsed.success) {
|
|
832
|
+
setImageError("Malformed upload response.");
|
|
833
|
+
setImagePhase("idle");
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
setImagePhase("ingesting");
|
|
837
|
+
const ing = await request<unknown>(hugpyConfig.videoIngestUrl, {
|
|
838
|
+
method: "POST",
|
|
839
|
+
body: JSON.stringify({ path: upParsed.data.path }),
|
|
840
|
+
headers: { "Content-Type": "application/json" },
|
|
841
|
+
meta: { specKey: "studio", operation: "studio.subject.ingest" },
|
|
842
|
+
});
|
|
843
|
+
if (!mounted.current) return;
|
|
844
|
+
if (!ing.ok) {
|
|
845
|
+
setImageError(describeAppError(errorOf(ing)));
|
|
846
|
+
setImagePhase("idle");
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
const media = mediaRefSchema.safeParse(okValue(ing));
|
|
850
|
+
if (!media.success) {
|
|
851
|
+
setImageError("Malformed ingest response.");
|
|
852
|
+
setImagePhase("idle");
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
if (media.data.kind !== "video") {
|
|
856
|
+
setImageError(`Ingested asset is "${media.data.kind}", not a video — pick a clip.`);
|
|
857
|
+
setImagePhase("idle");
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
addToLibrary(media.data, "upload", media.data.mime);
|
|
861
|
+
setSource(media.data);
|
|
862
|
+
setSourcePreview(false);
|
|
863
|
+
setShowSourcePicker(false);
|
|
864
|
+
setPendingSubjectStage(false);
|
|
865
|
+
setAwaitingSubjectJobId(null);
|
|
866
|
+
setImagePhase("idle");
|
|
867
|
+
},
|
|
868
|
+
[setSource],
|
|
869
|
+
);
|
|
870
|
+
|
|
871
|
+
// ── "generate new clip" — the switcher's third swap path. Clears the staged
|
|
872
|
+
// subject, forces the no-source t2v shape (renderSource itself stops rendering in
|
|
873
|
+
// t2v, so the always-visible note below carries the "what's happening" signal in
|
|
874
|
+
// its place), and arms pendingSubjectStage so the NEXT successful enqueue starts the
|
|
875
|
+
// auto-stage wait below. ──
|
|
876
|
+
const onGenerateNewSubject = useCallback(() => {
|
|
877
|
+
setSource(null);
|
|
878
|
+
setSourcePreview(false);
|
|
879
|
+
setShowSourcePicker(false);
|
|
880
|
+
setAwaitingSubjectJobId(null);
|
|
881
|
+
setCapability("t2v");
|
|
882
|
+
setPresetId("");
|
|
883
|
+
setPendingSubjectStage(true);
|
|
884
|
+
setGenMsg(null);
|
|
885
|
+
}, [setSource]);
|
|
886
|
+
|
|
887
|
+
// Watch the shared clip list for the job armed by onGenerateNewSubject (via
|
|
888
|
+
// onGenerate below). `clips` is the SAME 6s-polled list the viewer/library read, so
|
|
889
|
+
// this rides that poll instead of opening a second one. Once the awaited job shows
|
|
890
|
+
// up playable, its clip becomes the staged subject automatically; a failed/cancelled
|
|
891
|
+
// job stops the wait with an honest message instead of hanging forever.
|
|
892
|
+
useEffect(() => {
|
|
893
|
+
if (!awaitingSubjectJobId) return;
|
|
894
|
+
const match = clips.find((c) => c.job_id === awaitingSubjectJobId);
|
|
895
|
+
if (!match) return; // not in the list yet — keep waiting for the next poll tick
|
|
896
|
+
if (match.playable) {
|
|
897
|
+
const ref = clipToMediaRef(match);
|
|
898
|
+
if (ref) {
|
|
899
|
+
setSource(ref);
|
|
900
|
+
setSourcePreview(false);
|
|
901
|
+
setGenMsg("The new clip is done — staged as the subject.");
|
|
902
|
+
}
|
|
903
|
+
setAwaitingSubjectJobId(null);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
const st = match.status ?? "";
|
|
907
|
+
if (st === "failed" || st === "cancelled") {
|
|
908
|
+
setGenMsg(`The subject render ${st} — add a subject another way, or try Generate again.`);
|
|
909
|
+
setAwaitingSubjectJobId(null);
|
|
910
|
+
}
|
|
911
|
+
}, [clips, awaitingSubjectJobId, setSource]);
|
|
912
|
+
|
|
913
|
+
// ── required-input gate + preformed SHAPE ──
|
|
914
|
+
// v2v: a source clip is REQUIRED (nothing to repaint otherwise) — always the hero.
|
|
915
|
+
// i2v with a requires_source preset: a START IMAGE or a SOURCE CLIP (extend) is
|
|
916
|
+
// required — the start image is the hero, the source clip the alternative.
|
|
917
|
+
// id_lock: >=1 REFERENCE image is required (the route 400s otherwise) — reference
|
|
918
|
+
// slots are the hero; the composition control is optional + both-or-neither.
|
|
919
|
+
// t2v / synthetic i2v / custom: no required input; inputs stay inline + optional.
|
|
920
|
+
const presetNeedsInput = preset?.requires_source === true;
|
|
921
|
+
const hasImage = startImages.length > 0;
|
|
922
|
+
const hasSource = !!source;
|
|
923
|
+
const hasReference = referenceImages.length > 0;
|
|
924
|
+
const canAddImage = startImages.length < MAX_START_IMAGES;
|
|
925
|
+
const canAddReference = referenceImages.length < MAX_REFERENCE_IMAGES;
|
|
926
|
+
const needSource = isV2V;
|
|
927
|
+
const needImageOrSource = isI2V && presetNeedsInput;
|
|
928
|
+
const needReference = isIdLock;
|
|
929
|
+
// MOTION requires a control still and it IS the capability — the exact mirror of
|
|
930
|
+
// id_lock's reference rule (video_routes: "a motion request with no control carries NO
|
|
931
|
+
// conditioning this runner can tell apart from a plain t2v/v2v render").
|
|
932
|
+
const needControl = isMotion;
|
|
933
|
+
const hasControl = !!controlImage;
|
|
934
|
+
// both-or-neither: image XOR kind set is invalid. Applies to BOTH capabilities that
|
|
935
|
+
// accept a control (id_lock optional, motion required).
|
|
936
|
+
const controlHalfSet = (isIdLock || isMotion) && !!controlImage !== (controlKind !== "");
|
|
937
|
+
// The route refuses this pair by name: the VACE control channel takes ONE input and
|
|
938
|
+
// source_video wins the elif, so the control still would be silently dropped and the
|
|
939
|
+
// render would come back a plain restyle. Block it here rather than let the 400 land.
|
|
940
|
+
const controlAndSource = isMotion && hasControl && hasSource;
|
|
941
|
+
const missingRequired =
|
|
942
|
+
(needSource && !hasSource) ||
|
|
943
|
+
(needImageOrSource && !hasImage && !hasSource) ||
|
|
944
|
+
(needReference && !hasReference) ||
|
|
945
|
+
(needControl && !hasControl) ||
|
|
946
|
+
controlAndSource ||
|
|
947
|
+
controlHalfSet;
|
|
948
|
+
|
|
949
|
+
const hero: "start-image" | "source" | "reference" | "control" | null = isMotion
|
|
950
|
+
? "control"
|
|
951
|
+
: isIdLock
|
|
952
|
+
? "reference"
|
|
953
|
+
: isV2V
|
|
954
|
+
? "source"
|
|
955
|
+
: needImageOrSource
|
|
956
|
+
? "start-image"
|
|
957
|
+
: null;
|
|
958
|
+
const startImageAttention = needImageOrSource && !hasImage && !hasSource;
|
|
959
|
+
const sourceAttention =
|
|
960
|
+
(needSource && !hasSource) || (needImageOrSource && !hasImage && !hasSource);
|
|
961
|
+
const referenceAttention = needReference && !hasReference;
|
|
962
|
+
|
|
963
|
+
// ── Composer attach idiom (operator ask 2026-07-11, option A) ──
|
|
964
|
+
// The prompt composer's inline attach toolbar + chips and the hero shape editor below
|
|
965
|
+
// are TWO ACCESS PATHS TO THE SAME STATE — one source of truth, two views. We adopt
|
|
966
|
+
// GoalComposer's documented attach idiom (attachments = toolbar buttons, attached =
|
|
967
|
+
// removable chips) and wire its callbacks to THIS file's OWN pickers / MediaRef flows,
|
|
968
|
+
// offering only the inputs the ACTIVE shape accepts (mirroring the render* predicates
|
|
969
|
+
// above). The buttons open the SAME `showLibPicker` / `showSourcePicker` the hero uses,
|
|
970
|
+
// so the library pick routes to start (i2v) or reference (v2v / id_lock) exactly as the
|
|
971
|
+
// hero's own lib-picker routing does; the chips reflect the SAME startImages / source /
|
|
972
|
+
// referenceImages / controlImage state the hero renders, and reuse the hero's existing
|
|
973
|
+
// removes. Applicability decides whether a button EXISTS (t2v gets no toolbar — omitted
|
|
974
|
+
// cleanly by GoalComposer when the array is empty); capacity decides whether it is
|
|
975
|
+
// DISABLED (a filled slot mirrors the hero's "you're at the cap" note, not a vanished
|
|
976
|
+
// affordance). The negative composer stays plain.
|
|
977
|
+
//
|
|
978
|
+
// 2026-07-29: these three are no longer hand-written capability unions — they are the
|
|
979
|
+
// route's own input gates, transcribed once in renderCompat's ACCEPTS matrix and read
|
|
980
|
+
// here via `acceptedSlots`. Same values for the four original capabilities; `motion`
|
|
981
|
+
// gets its control slot for free, and a future gate change is a one-line edit in the
|
|
982
|
+
// module that mirrors the route rather than a hunt through two components.
|
|
983
|
+
const acceptsStartImage = slots.startImage; // i2v carries the subject from a start still
|
|
984
|
+
const acceptsSourceClip = slots.sourceVideo; // i2v extend / v2v restyle take a source clip
|
|
985
|
+
const acceptsReferenceImage = slots.referenceImages; // v2v continuity / id_lock lock
|
|
986
|
+
|
|
987
|
+
const promptAttachments: GoalComposerAttach[] = [];
|
|
988
|
+
// i2v ⇒ start image; v2v / id_lock ⇒ reference image (mutually exclusive — never both).
|
|
989
|
+
if (acceptsStartImage) {
|
|
990
|
+
promptAttachments.push({
|
|
991
|
+
label: "+ image from library",
|
|
992
|
+
onClick: () => setShowLibPicker(true),
|
|
993
|
+
disabled: !canAddImage,
|
|
994
|
+
title: canAddImage
|
|
995
|
+
? "Pick a start image from the session library"
|
|
996
|
+
: "A start image is already added — remove it to pick another.",
|
|
997
|
+
});
|
|
998
|
+
} else if (acceptsReferenceImage) {
|
|
999
|
+
promptAttachments.push({
|
|
1000
|
+
label: "+ reference image",
|
|
1001
|
+
onClick: () => setShowLibPicker(true),
|
|
1002
|
+
disabled: !canAddReference,
|
|
1003
|
+
title: canAddReference
|
|
1004
|
+
? "Pick a reference image from the session library"
|
|
1005
|
+
: `Maximum ${MAX_REFERENCE_IMAGES} reference images.`,
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
if (acceptsSourceClip) {
|
|
1009
|
+
promptAttachments.push({
|
|
1010
|
+
label: "+ video",
|
|
1011
|
+
onClick: () => setShowSourcePicker(true),
|
|
1012
|
+
title: isV2V ? "Pick the source clip to restyle" : "Pick a source clip to extend",
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// Chips mirror the picked state the ACTIVE shape shows, with the hero's own removes.
|
|
1017
|
+
const promptAttached: GoalComposerAttached[] = [];
|
|
1018
|
+
if (acceptsStartImage) {
|
|
1019
|
+
startImages.forEach((img, idx) =>
|
|
1020
|
+
promptAttached.push({
|
|
1021
|
+
ref: img,
|
|
1022
|
+
label: "start image",
|
|
1023
|
+
onRemove: () => removeStartImage(idx),
|
|
1024
|
+
}),
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
if (acceptsSourceClip && source) {
|
|
1028
|
+
promptAttached.push({
|
|
1029
|
+
ref: source,
|
|
1030
|
+
label: source.asset_id.slice(0, 8),
|
|
1031
|
+
// Same clear the hero's Clear button performs (drop the ref + stop any preview).
|
|
1032
|
+
onRemove: () => {
|
|
1033
|
+
setSource(null);
|
|
1034
|
+
setSourcePreview(false);
|
|
1035
|
+
},
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
if (acceptsReferenceImage) {
|
|
1039
|
+
referenceImages.forEach((slot, idx) =>
|
|
1040
|
+
promptAttached.push({
|
|
1041
|
+
ref: slot.ref,
|
|
1042
|
+
label: `ref ${idx + 1}`,
|
|
1043
|
+
onRemove: () => removeReferenceImage(idx),
|
|
1044
|
+
}),
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
if (slots.controlImage && controlImage) {
|
|
1048
|
+
promptAttached.push({
|
|
1049
|
+
ref: controlImage,
|
|
1050
|
+
// Mirrors the hero's control Remove (setControlImage(null)); kind is left as-is,
|
|
1051
|
+
// exactly like that button — the both-or-neither gate then flags the half-set state.
|
|
1052
|
+
label: controlKind ? `control · ${controlKind}` : "control",
|
|
1053
|
+
onRemove: () => setControlImage(null),
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// Preset-aware honesty copy for the id_lock reference hero (lock-person / lock-thing /
|
|
1058
|
+
// scene-bias templates each want different guidance; the generic id_lock keeps a
|
|
1059
|
+
// neutral line). Shown as the accent callout while the required refs are empty.
|
|
1060
|
+
const referenceCallout =
|
|
1061
|
+
presetId === "lock-person"
|
|
1062
|
+
? "Add 1–4 photos of the SAME person; multiple angles (front, 3/4, profile) help hold the face and build across motion. Order matters."
|
|
1063
|
+
: presetId === "lock-thing"
|
|
1064
|
+
? "Add up to 4 clean shots of the object (a plain background and a couple of angles help). Its shape, markings and colour are preserved. Order matters."
|
|
1065
|
+
: presetId === "scene-bias"
|
|
1066
|
+
? "Add a photo of the place. Honest: a place BIASES the setting, lighting and palette — it does not hard-lock like a person or object does."
|
|
1067
|
+
: `This generator locks identity to these reference images — add 1–${MAX_REFERENCE_IMAGES} (a person, place, or thing). Order matters.`;
|
|
1068
|
+
|
|
1069
|
+
const missingReason =
|
|
1070
|
+
needReference && !hasReference
|
|
1071
|
+
? "Identity lock needs at least one reference image (1–4) — add one above."
|
|
1072
|
+
: needControl && !hasControl
|
|
1073
|
+
? "Pose / structure control needs a control still (pose, depth or sketch) — it IS the control that makes this a motion render rather than a plain restyle."
|
|
1074
|
+
: controlAndSource
|
|
1075
|
+
? "A control still and a source clip cannot be combined — the control channel takes one input and the source clip wins, so the still would be silently ignored. Clear one (control still ⇒ motion, source clip ⇒ restyle)."
|
|
1076
|
+
: controlHalfSet
|
|
1077
|
+
? "Composition control is both-or-neither — add a control image AND a kind, or clear both."
|
|
1078
|
+
: needSource && !hasSource
|
|
1079
|
+
? "Restyle (v2v) needs a source clip — add one above."
|
|
1080
|
+
: "This generator needs a start image (or a source clip to extend) — add one above.";
|
|
1081
|
+
|
|
1082
|
+
// v2v stays a HARD block (the router refuses an out-of-envelope restyle outright). The
|
|
1083
|
+
// envelope itself is now the ratified preset's rather than a mirrored constant.
|
|
1084
|
+
const v2vW = capGeometry?.width ?? VACE_W;
|
|
1085
|
+
const v2vH = capGeometry?.height ?? VACE_H;
|
|
1086
|
+
const v2vBadGeom =
|
|
1087
|
+
isV2V && (width > v2vW || height > v2vH || width <= 0 || height <= 0);
|
|
1088
|
+
const blockSubmit = missingRequired || v2vBadGeom;
|
|
1089
|
+
|
|
1090
|
+
// ── SYNTHETIC-TIER HONESTY BANNER (operator ask 2026-07-12) ──
|
|
1091
|
+
// "Leaving VRAM budget blank silently binds the synthetic (colored-noise) tier"
|
|
1092
|
+
// bit the operator repeatedly — the footnote on the knob below was never enough.
|
|
1093
|
+
// Only t2v and i2v have a synthetic last-resort model registered at all
|
|
1094
|
+
// (studio/models_seed.py: synthetic-t2v / synthetic-i2v are the only synthetic
|
|
1095
|
+
// ModelConfigs) — v2v and id_lock have NO synthetic candidate, so a too-low
|
|
1096
|
+
// budget there fails the router honestly instead of rendering fake frames. That
|
|
1097
|
+
// structurally excludes both: isV2V is caught by `!(isT2V || isI2V)` directly,
|
|
1098
|
+
// and separately v2v's own footgun-guard effect above already floors a blank
|
|
1099
|
+
// budget to VACE_BUDGET_GB the moment restyle is picked — so this predicate
|
|
1100
|
+
// mirrors the surface's OWN "would this bind sub-real" logic rather than
|
|
1101
|
+
// re-deriving a parallel one.
|
|
1102
|
+
//
|
|
1103
|
+
// ⚠ THE FLOOR IS NOW MEASURED, NOT MIRRORED (2026-07-29). `STUDIO_REAL_FLOOR_GB` was a
|
|
1104
|
+
// hand-kept copy of a backend constant and it had drifted to 6 — UNDER the cheapest
|
|
1105
|
+
// real row in the ratified table (wan2.1-t2v-1.3b @fp16 is 8.2). A budget of 7 would
|
|
1106
|
+
// therefore bind the synthetic prover while this banner stayed silent. The floor now
|
|
1107
|
+
// comes from the capability's own `vram_envelope_gb`, with the old constant kept only
|
|
1108
|
+
// as the fallback for a page whose discovery GET has not landed.
|
|
1109
|
+
const realFloorGb = capFloorGb ?? STUDIO_REAL_FLOOR_GB;
|
|
1110
|
+
const safeFillGb = capSuggestGb ?? STUDIO_SAFE_FILL_GB;
|
|
1111
|
+
const wouldBindSynthetic =
|
|
1112
|
+
(isT2V || isI2V) && bindsSyntheticTier(vramBudget, realFloorGb);
|
|
1113
|
+
|
|
1114
|
+
const onGenerate = useCallback(async () => {
|
|
1115
|
+
if (blockSubmit) {
|
|
1116
|
+
setGenMsg(
|
|
1117
|
+
v2vBadGeom
|
|
1118
|
+
? "Restyle geometry must be 832×480 landscape (the VACE envelope)."
|
|
1119
|
+
: missingReason,
|
|
1120
|
+
);
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
const budgetNum = vramBudget.trim() === "" ? undefined : Number(vramBudget);
|
|
1124
|
+
if (budgetNum !== undefined && (Number.isNaN(budgetNum) || budgetNum <= 0)) {
|
|
1125
|
+
setGenMsg("VRAM budget must be a positive number (or blank for the route default).");
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
const stepsNum = steps.trim() === "" ? undefined : Number(steps);
|
|
1129
|
+
if (
|
|
1130
|
+
stepsNum !== undefined &&
|
|
1131
|
+
(!Number.isInteger(stepsNum) || stepsNum < 1 || stepsNum > 100)
|
|
1132
|
+
) {
|
|
1133
|
+
setGenMsg("Steps must be a whole number in [1, 100] (or blank for the model default).");
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
const cfgNum = cfg.trim() === "" ? undefined : Number(cfg);
|
|
1137
|
+
if (cfgNum !== undefined && (Number.isNaN(cfgNum) || cfgNum < 0 || cfgNum > 20)) {
|
|
1138
|
+
setGenMsg("CFG must be a number in [0, 20] (or blank for the model default).");
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
setGenBusy(true);
|
|
1143
|
+
setGenMsg(null);
|
|
1144
|
+
try {
|
|
1145
|
+
// The route accepts control_image on id_lock (optional composition blocking) AND
|
|
1146
|
+
// on motion (required, definitional) — `slots.controlImage` is that gate.
|
|
1147
|
+
const controlReady = (isIdLock || isMotion) && !!controlImage && controlKind !== "";
|
|
1148
|
+
const body: Record<string, unknown> = {
|
|
1149
|
+
capability,
|
|
1150
|
+
resolution: { width, height, fps },
|
|
1151
|
+
seed,
|
|
1152
|
+
vram_budget_gb: budgetNum,
|
|
1153
|
+
steps: stepsNum,
|
|
1154
|
+
cfg: cfgNum,
|
|
1155
|
+
model_id: modelId.trim() ? modelId.trim() : undefined,
|
|
1156
|
+
// Optional auto-archive NAME (choose-or-type). Blank = auto-named (default).
|
|
1157
|
+
project: project.trim() ? project.trim() : undefined,
|
|
1158
|
+
prompt: prompt.trim() ? prompt : undefined,
|
|
1159
|
+
// k89: the standard set is composed in when the card's tick is on — an
|
|
1160
|
+
// empty user negative + the tick still sends the standard exclusions.
|
|
1161
|
+
negative_prompt: (() => {
|
|
1162
|
+
const eff = composeNegative(negative, stdNegative);
|
|
1163
|
+
return eff !== "" ? eff : undefined;
|
|
1164
|
+
})(),
|
|
1165
|
+
// i2v start image (single first frame). t2v/v2v/id_lock drop it.
|
|
1166
|
+
start_image: isI2V && startImages[0] ? startImages[0].uri : undefined,
|
|
1167
|
+
// Source clip: i2v (extend) / v2v (restyle) only. Prefer the jail-resolvable
|
|
1168
|
+
// abs uri; fall back to the catalog asset id.
|
|
1169
|
+
source_video: (isI2V || isV2V) && source?.uri ? source.uri : undefined,
|
|
1170
|
+
source_asset_id:
|
|
1171
|
+
(isI2V || isV2V) && source && !source.uri ? source.asset_id : undefined,
|
|
1172
|
+
// IDENTITY (unified): a SAVED profile is the canonical identity — send its slug
|
|
1173
|
+
// (the route resolves it server-side; a later profile edit re-resolves). Raw
|
|
1174
|
+
// reference_images ride only for an UNSAVED identity (no profile bound).
|
|
1175
|
+
identity_profile:
|
|
1176
|
+
(isIdLock || isV2V) && selectedProfile ? selectedProfile.slug : undefined,
|
|
1177
|
+
// ORDERED reference images (the hash keys on order — preserve it). id_lock: the
|
|
1178
|
+
// identity to lock (required). v2v: an OPTIONAL identity held across the restyle
|
|
1179
|
+
// (scene-continuity). The route accepts reference_images on BOTH capabilities.
|
|
1180
|
+
reference_images:
|
|
1181
|
+
(isIdLock || isV2V) && !selectedProfile && referenceImages.length
|
|
1182
|
+
? referenceImages.map((s) => s.ref.uri)
|
|
1183
|
+
: undefined,
|
|
1184
|
+
// id_lock optional composition control (both-or-neither; sent only when both set).
|
|
1185
|
+
control_image: controlReady ? (controlImage as MediaRef).uri : undefined,
|
|
1186
|
+
control_kind: controlReady ? controlKind : undefined,
|
|
1187
|
+
};
|
|
1188
|
+
const res = await request<unknown>(hugpyConfig.studioI2VEnqueueUrl, {
|
|
1189
|
+
method: "POST",
|
|
1190
|
+
headers: { "Content-Type": "application/json" },
|
|
1191
|
+
body: JSON.stringify(body),
|
|
1192
|
+
meta: { specKey: "studio", operation: "studio.i2v.enqueue" },
|
|
1193
|
+
});
|
|
1194
|
+
if (!mounted.current) return;
|
|
1195
|
+
if (!res.ok) {
|
|
1196
|
+
setGenMsg(`Enqueue failed: ${describeAppError(errorOf(res))}`);
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
// Subject-clip auto-stage (armed by onGenerateNewSubject): the FIRST successful
|
|
1200
|
+
// enqueue after "Generate new clip" arms the wait on ITS job id — later manual
|
|
1201
|
+
// generates (pendingSubjectStage already false by then) are plain renders.
|
|
1202
|
+
if (pendingSubjectStage) {
|
|
1203
|
+
setPendingSubjectStage(false);
|
|
1204
|
+
const enq = enqueueResultSchema.safeParse(okValue(res));
|
|
1205
|
+
if (enq.success) {
|
|
1206
|
+
setAwaitingSubjectJobId(enq.data.job_id);
|
|
1207
|
+
setGenMsg(
|
|
1208
|
+
"Enqueued — once this clip is produced it becomes the staged subject automatically.",
|
|
1209
|
+
);
|
|
1210
|
+
} else {
|
|
1211
|
+
setGenMsg(
|
|
1212
|
+
"Enqueued, but no job id came back to track — stage the produced clip as the subject manually (From library) once it's done.",
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
} else {
|
|
1216
|
+
setGenMsg("Enqueued — once the clip is produced it plays in the viewer above and lands in the Session Library.");
|
|
1217
|
+
}
|
|
1218
|
+
onEnqueued();
|
|
1219
|
+
// A project name coined here should appear in the combobox next time.
|
|
1220
|
+
if (project.trim()) refreshProjects();
|
|
1221
|
+
} finally {
|
|
1222
|
+
if (mounted.current) setGenBusy(false);
|
|
1223
|
+
}
|
|
1224
|
+
}, [
|
|
1225
|
+
blockSubmit,
|
|
1226
|
+
v2vBadGeom,
|
|
1227
|
+
missingReason,
|
|
1228
|
+
capability,
|
|
1229
|
+
width,
|
|
1230
|
+
height,
|
|
1231
|
+
fps,
|
|
1232
|
+
seed,
|
|
1233
|
+
vramBudget,
|
|
1234
|
+
steps,
|
|
1235
|
+
cfg,
|
|
1236
|
+
modelId,
|
|
1237
|
+
prompt,
|
|
1238
|
+
negative,
|
|
1239
|
+
stdNegative,
|
|
1240
|
+
isI2V,
|
|
1241
|
+
isV2V,
|
|
1242
|
+
isIdLock,
|
|
1243
|
+
isMotion,
|
|
1244
|
+
startImages,
|
|
1245
|
+
source,
|
|
1246
|
+
referenceImages,
|
|
1247
|
+
selectedProfile,
|
|
1248
|
+
controlImage,
|
|
1249
|
+
controlKind,
|
|
1250
|
+
pendingSubjectStage,
|
|
1251
|
+
onEnqueued,
|
|
1252
|
+
project,
|
|
1253
|
+
refreshProjects,
|
|
1254
|
+
]);
|
|
1255
|
+
|
|
1256
|
+
const synthPresets = presets.filter((p) => isSyntheticPreset(p));
|
|
1257
|
+
const realPresets = presets.filter((p) => !isSyntheticPreset(p));
|
|
1258
|
+
// The identity-lock family (id_lock TEMPLATES + the v2v scene-continuity template) gets
|
|
1259
|
+
// its OWN first-class group so the lock/continuity cards are not buried among the plain
|
|
1260
|
+
// t2v/i2v/v2v tiers.
|
|
1261
|
+
const lockPresets = realPresets.filter(
|
|
1262
|
+
(p) => p.capability === "id_lock" || p.id === "scene-continuity",
|
|
1263
|
+
);
|
|
1264
|
+
const generalPresets = realPresets.filter((p) => !lockPresets.includes(p));
|
|
1265
|
+
const sourceClips = clips.filter((c) => c.playable && c.output?.uri);
|
|
1266
|
+
|
|
1267
|
+
// The image-library thumbnail grid used below is now the SHARED
|
|
1268
|
+
// `LibraryImageGrid` (studioShared.tsx) — extracted out of this file's original
|
|
1269
|
+
// inline closure so the Movie composer's start-image switcher reuses the exact
|
|
1270
|
+
// same picker instead of a parallel implementation (operator ask 2026-07-12).
|
|
1271
|
+
|
|
1272
|
+
// A preset card (rendered as a <button> so `key` sits on an intrinsic element).
|
|
1273
|
+
function presetCard(p: StudioPreset) {
|
|
1274
|
+
const selected = p.id === presetId;
|
|
1275
|
+
return (
|
|
1276
|
+
<button
|
|
1277
|
+
key={p.id}
|
|
1278
|
+
type="button"
|
|
1279
|
+
className={selected ? "vi-btn vi-btn-accent" : "vi-btn vi-btn-ghost"}
|
|
1280
|
+
onClick={() => onPickPreset(p.id)}
|
|
1281
|
+
aria-pressed={selected}
|
|
1282
|
+
style={{
|
|
1283
|
+
display: "flex",
|
|
1284
|
+
flexDirection: "column",
|
|
1285
|
+
alignItems: "flex-start",
|
|
1286
|
+
gap: "0.2rem",
|
|
1287
|
+
textAlign: "left",
|
|
1288
|
+
padding: "0.55rem 0.7rem",
|
|
1289
|
+
flex: "1 1 15rem",
|
|
1290
|
+
minWidth: "13rem",
|
|
1291
|
+
maxWidth: "20rem",
|
|
1292
|
+
whiteSpace: "normal",
|
|
1293
|
+
height: "auto",
|
|
1294
|
+
}}
|
|
1295
|
+
>
|
|
1296
|
+
<span style={{ fontWeight: 600 }}>{p.name}</span>
|
|
1297
|
+
<span style={{ fontSize: "0.78rem", opacity: 0.85 }}>
|
|
1298
|
+
{p.capability}
|
|
1299
|
+
{typeof p.vram_budget_gb === "number" ? ` · ${p.vram_budget_gb} GB` : ""}
|
|
1300
|
+
{p.requires_source || p.requires_reference ? " · needs input" : ""}
|
|
1301
|
+
{p.recommended ? ` · ${p.recommended}` : ""}
|
|
1302
|
+
</span>
|
|
1303
|
+
{p.description ? (
|
|
1304
|
+
<span style={{ fontSize: "0.75rem", opacity: 0.7, lineHeight: 1.35 }}>
|
|
1305
|
+
{p.description}
|
|
1306
|
+
</span>
|
|
1307
|
+
) : null}
|
|
1308
|
+
{p.prompt_note ? (
|
|
1309
|
+
<span style={{ fontSize: "0.72rem", fontStyle: "italic", opacity: 0.7 }}>
|
|
1310
|
+
ⓘ {p.prompt_note}
|
|
1311
|
+
</span>
|
|
1312
|
+
) : null}
|
|
1313
|
+
</button>
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// ── start-image input (variant-driven: hero = dominant/accented; inline = subdued) ──
|
|
1318
|
+
function renderStartImage(variant: "hero" | "inline") {
|
|
1319
|
+
const isHero = variant === "hero";
|
|
1320
|
+
const drawEye = isHero && startImageAttention;
|
|
1321
|
+
const thumb = isHero ? "9rem" : "5rem";
|
|
1322
|
+
return (
|
|
1323
|
+
<section aria-label="Start image" style={{ marginTop: isHero ? 0 : "0.9rem" }}>
|
|
1324
|
+
<p
|
|
1325
|
+
className="vi-comfy-label"
|
|
1326
|
+
style={{ marginBottom: "0.35rem", opacity: isHero ? 1 : 0.75 }}
|
|
1327
|
+
>
|
|
1328
|
+
Start image{isHero ? " — required for this generator" : " (optional)"}
|
|
1329
|
+
</p>
|
|
1330
|
+
{drawEye && (
|
|
1331
|
+
<p className="vi-input-callout" role="note">
|
|
1332
|
+
This generator needs a start image — the subject is carried from it. Drop one below
|
|
1333
|
+
(or extend a source clip instead).
|
|
1334
|
+
</p>
|
|
1335
|
+
)}
|
|
1336
|
+
{hasImage && (
|
|
1337
|
+
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginBottom: "0.5rem" }}>
|
|
1338
|
+
{startImages.map((img, idx) => (
|
|
1339
|
+
<div key={img.uri} style={{ display: "flex", flexDirection: "column", gap: "0.2rem" }}>
|
|
1340
|
+
<img
|
|
1341
|
+
src={mediaBytesUrl(img.uri)}
|
|
1342
|
+
alt="start"
|
|
1343
|
+
style={{
|
|
1344
|
+
width: thumb,
|
|
1345
|
+
height: thumb,
|
|
1346
|
+
objectFit: "cover",
|
|
1347
|
+
borderRadius: "0.4rem",
|
|
1348
|
+
background: "#000",
|
|
1349
|
+
border: "1px solid var(--vi-border)",
|
|
1350
|
+
}}
|
|
1351
|
+
/>
|
|
1352
|
+
<button
|
|
1353
|
+
type="button"
|
|
1354
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
1355
|
+
onClick={() => removeStartImage(idx)}
|
|
1356
|
+
>
|
|
1357
|
+
Remove
|
|
1358
|
+
</button>
|
|
1359
|
+
</div>
|
|
1360
|
+
))}
|
|
1361
|
+
</div>
|
|
1362
|
+
)}
|
|
1363
|
+
{canAddImage ? (
|
|
1364
|
+
isHero && !hasImage ? (
|
|
1365
|
+
<DropReceptacle
|
|
1366
|
+
onFile={(f) => void onPickImageFile(f, "start")}
|
|
1367
|
+
accept="image/*"
|
|
1368
|
+
busy={imageBusy}
|
|
1369
|
+
title="Drop a start image or click to browse"
|
|
1370
|
+
hint="This generator carries the subject from this still."
|
|
1371
|
+
className={drawEye ? "vi-dropzone--required" : undefined}
|
|
1372
|
+
/>
|
|
1373
|
+
) : (
|
|
1374
|
+
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
|
|
1375
|
+
<label className="vi-btn vi-btn-accent vi-file-label">
|
|
1376
|
+
{imageBusy ? "Working…" : "Upload a still"}
|
|
1377
|
+
<input
|
|
1378
|
+
type="file"
|
|
1379
|
+
accept="image/*"
|
|
1380
|
+
className="vi-file-input"
|
|
1381
|
+
disabled={imageBusy}
|
|
1382
|
+
onChange={(e) => {
|
|
1383
|
+
const f = e.target.files?.[0];
|
|
1384
|
+
if (f) void onPickImageFile(f, "start");
|
|
1385
|
+
e.target.value = "";
|
|
1386
|
+
}}
|
|
1387
|
+
/>
|
|
1388
|
+
</label>
|
|
1389
|
+
<button
|
|
1390
|
+
type="button"
|
|
1391
|
+
className="vi-btn vi-btn-ghost"
|
|
1392
|
+
onClick={() => setShowLibPicker((v) => !v)}
|
|
1393
|
+
aria-expanded={showLibPicker}
|
|
1394
|
+
>
|
|
1395
|
+
{showLibPicker ? "Hide library" : `Pick from library (${libraryImages.length})`}
|
|
1396
|
+
</button>
|
|
1397
|
+
</div>
|
|
1398
|
+
)
|
|
1399
|
+
) : (
|
|
1400
|
+
<p className="vi-comfy-hint" role="note">
|
|
1401
|
+
Multi-image conditioning here is identity lock — switch capability to Identity lock to
|
|
1402
|
+
add 1–4 reference images.
|
|
1403
|
+
</p>
|
|
1404
|
+
)}
|
|
1405
|
+
{imageError && (
|
|
1406
|
+
<p className="vi-error" role="alert" style={{ marginTop: "0.4rem" }}>
|
|
1407
|
+
{imageError}
|
|
1408
|
+
</p>
|
|
1409
|
+
)}
|
|
1410
|
+
{showLibPicker && canAddImage && (
|
|
1411
|
+
<LibraryImageGrid images={libraryImages} onPick={(ref) => addStartImage(ref)} />
|
|
1412
|
+
)}
|
|
1413
|
+
</section>
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// ── reference-image SLOT LIST: 1–4 ordered stills, reorderable. The SAME list feeds
|
|
1418
|
+
// two shapes — id_lock (variant "required": the identity to lock, accent-gated) and
|
|
1419
|
+
// v2v scene-continuity (variant "optional": an identity held across the restyle). ──
|
|
1420
|
+
function renderReferenceSlots(variant: "required" | "optional") {
|
|
1421
|
+
const required = variant === "required";
|
|
1422
|
+
const drawEye = required && referenceAttention;
|
|
1423
|
+
return (
|
|
1424
|
+
<section aria-label="Reference images" style={{ marginTop: 0 }}>
|
|
1425
|
+
<p
|
|
1426
|
+
className="vi-comfy-label"
|
|
1427
|
+
style={{ marginBottom: "0.35rem", opacity: required ? 1 : 0.75 }}
|
|
1428
|
+
>
|
|
1429
|
+
{required
|
|
1430
|
+
? `Reference images — required (1–${MAX_REFERENCE_IMAGES}, order matters)`
|
|
1431
|
+
: `Hold an identity (optional) — 1–${MAX_REFERENCE_IMAGES} reference images, order matters`}
|
|
1432
|
+
</p>
|
|
1433
|
+
{required ? (
|
|
1434
|
+
drawEye && (
|
|
1435
|
+
<p className="vi-input-callout" role="note">
|
|
1436
|
+
{referenceCallout}
|
|
1437
|
+
</p>
|
|
1438
|
+
)
|
|
1439
|
+
) : (
|
|
1440
|
+
<p className="vi-comfy-hint" role="note" style={{ marginTop: 0 }}>
|
|
1441
|
+
Optionally carry a subject's identity across the restyle — add 1–
|
|
1442
|
+
{MAX_REFERENCE_IMAGES} reference images (order matters). Leave empty for a plain restyle.
|
|
1443
|
+
</p>
|
|
1444
|
+
)}
|
|
1445
|
+
<div className={drawEye ? "vi-input-required" : undefined}>
|
|
1446
|
+
{hasReference && (
|
|
1447
|
+
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.6rem", marginBottom: "0.5rem" }}>
|
|
1448
|
+
{referenceImages.map((slot, idx) => (
|
|
1449
|
+
<div
|
|
1450
|
+
key={slot.key}
|
|
1451
|
+
style={{ display: "flex", flexDirection: "column", gap: "0.2rem", alignItems: "center" }}
|
|
1452
|
+
>
|
|
1453
|
+
<div style={{ position: "relative" }}>
|
|
1454
|
+
<img
|
|
1455
|
+
src={mediaBytesUrl(slot.ref.uri)}
|
|
1456
|
+
alt={`reference ${idx + 1}`}
|
|
1457
|
+
style={{
|
|
1458
|
+
width: "7rem",
|
|
1459
|
+
height: "7rem",
|
|
1460
|
+
objectFit: "cover",
|
|
1461
|
+
borderRadius: "0.4rem",
|
|
1462
|
+
background: "#000",
|
|
1463
|
+
border: "1px solid var(--vi-border)",
|
|
1464
|
+
}}
|
|
1465
|
+
/>
|
|
1466
|
+
<span
|
|
1467
|
+
aria-hidden="true"
|
|
1468
|
+
style={{
|
|
1469
|
+
position: "absolute",
|
|
1470
|
+
top: 3,
|
|
1471
|
+
left: 3,
|
|
1472
|
+
background: "var(--vi-accent)",
|
|
1473
|
+
color: "#000",
|
|
1474
|
+
borderRadius: "0.3rem",
|
|
1475
|
+
padding: "0 0.4rem",
|
|
1476
|
+
fontSize: "0.72rem",
|
|
1477
|
+
fontWeight: 700,
|
|
1478
|
+
fontVariantNumeric: "tabular-nums",
|
|
1479
|
+
}}
|
|
1480
|
+
>
|
|
1481
|
+
{idx + 1}
|
|
1482
|
+
</span>
|
|
1483
|
+
</div>
|
|
1484
|
+
<div style={{ display: "flex", gap: "0.2rem" }}>
|
|
1485
|
+
<button
|
|
1486
|
+
type="button"
|
|
1487
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
1488
|
+
disabled={idx === 0}
|
|
1489
|
+
onClick={() => moveReferenceImage(idx, -1)}
|
|
1490
|
+
title="Move earlier"
|
|
1491
|
+
>
|
|
1492
|
+
◀
|
|
1493
|
+
</button>
|
|
1494
|
+
<button
|
|
1495
|
+
type="button"
|
|
1496
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
1497
|
+
disabled={idx === referenceImages.length - 1}
|
|
1498
|
+
onClick={() => moveReferenceImage(idx, 1)}
|
|
1499
|
+
title="Move later"
|
|
1500
|
+
>
|
|
1501
|
+
▶
|
|
1502
|
+
</button>
|
|
1503
|
+
<button
|
|
1504
|
+
type="button"
|
|
1505
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
1506
|
+
onClick={() => removeReferenceImage(idx)}
|
|
1507
|
+
title="Remove"
|
|
1508
|
+
>
|
|
1509
|
+
✕
|
|
1510
|
+
</button>
|
|
1511
|
+
</div>
|
|
1512
|
+
</div>
|
|
1513
|
+
))}
|
|
1514
|
+
</div>
|
|
1515
|
+
)}
|
|
1516
|
+
{canAddReference ? (
|
|
1517
|
+
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
|
|
1518
|
+
<label className="vi-btn vi-btn-accent vi-file-label">
|
|
1519
|
+
{imageBusy ? "Working…" : hasReference ? "Add another reference" : "Upload a reference"}
|
|
1520
|
+
<input
|
|
1521
|
+
type="file"
|
|
1522
|
+
accept="image/*"
|
|
1523
|
+
className="vi-file-input"
|
|
1524
|
+
disabled={imageBusy}
|
|
1525
|
+
onChange={(e) => {
|
|
1526
|
+
const f = e.target.files?.[0];
|
|
1527
|
+
if (f) void onPickImageFile(f, "reference");
|
|
1528
|
+
e.target.value = "";
|
|
1529
|
+
}}
|
|
1530
|
+
/>
|
|
1531
|
+
</label>
|
|
1532
|
+
<button
|
|
1533
|
+
type="button"
|
|
1534
|
+
className="vi-btn vi-btn-ghost"
|
|
1535
|
+
onClick={() => setShowLibPicker((v) => !v)}
|
|
1536
|
+
aria-expanded={showLibPicker}
|
|
1537
|
+
>
|
|
1538
|
+
{showLibPicker ? "Hide library" : `Pick from library (${libraryImages.length})`}
|
|
1539
|
+
</button>
|
|
1540
|
+
<span className="vi-comfy-hint" style={{ opacity: 0.7 }}>
|
|
1541
|
+
{referenceImages.length}/{MAX_REFERENCE_IMAGES}
|
|
1542
|
+
</span>
|
|
1543
|
+
</div>
|
|
1544
|
+
) : (
|
|
1545
|
+
<p className="vi-comfy-hint" role="note">
|
|
1546
|
+
Maximum {MAX_REFERENCE_IMAGES} reference images.
|
|
1547
|
+
</p>
|
|
1548
|
+
)}
|
|
1549
|
+
{imageError && (
|
|
1550
|
+
<p className="vi-error" role="alert" style={{ marginTop: "0.4rem" }}>
|
|
1551
|
+
{imageError}
|
|
1552
|
+
</p>
|
|
1553
|
+
)}
|
|
1554
|
+
{showLibPicker && canAddReference && (
|
|
1555
|
+
<LibraryImageGrid images={libraryImages} onPick={(ref) => addReferenceImage(ref)} />
|
|
1556
|
+
)}
|
|
1557
|
+
{/* IDENTITY PROFILES (stage a): attach a saved identity profile's reference
|
|
1558
|
+
set, or save the current one — the durable form of this identity. */}
|
|
1559
|
+
<IdentityProfileControls
|
|
1560
|
+
refCount={referenceImages.length}
|
|
1561
|
+
currentRefUris={referenceImages.map((s) => s.ref.uri)}
|
|
1562
|
+
selectedProfile={selectedProfile}
|
|
1563
|
+
onAttach={attachIdentityRefs}
|
|
1564
|
+
onSelectProfile={setSelectedProfile}
|
|
1565
|
+
maxRefs={MAX_REFERENCE_IMAGES}
|
|
1566
|
+
disabled={imageBusy}
|
|
1567
|
+
/>
|
|
1568
|
+
</div>
|
|
1569
|
+
</section>
|
|
1570
|
+
);
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// ── id_lock OPTIONAL composition control (subdued): a single pose/depth/sketch still. ──
|
|
1574
|
+
// 2026-07-29: the SAME block now serves the `motion` capability, where the control
|
|
1575
|
+
// is not optional decoration but the whole render — so it takes the hero treatment
|
|
1576
|
+
// (accent gating + the required copy) exactly like the reference slots do on id_lock.
|
|
1577
|
+
function renderControl(variant: "optional" | "required" = "optional") {
|
|
1578
|
+
const required = variant === "required";
|
|
1579
|
+
const drawEye = required && !hasControl;
|
|
1580
|
+
return (
|
|
1581
|
+
<section aria-label="Composition control" style={{ marginTop: required ? 0 : "0.9rem" }}>
|
|
1582
|
+
<p className="vi-comfy-label" style={{ opacity: required ? 1 : 0.75, marginBottom: "0.2rem" }}>
|
|
1583
|
+
{required
|
|
1584
|
+
? "Control still — required (pose, depth or sketch)"
|
|
1585
|
+
: "Composition control (optional)"}
|
|
1586
|
+
</p>
|
|
1587
|
+
{/* Conditional required-input warning — KEPT (functional, k89), wording
|
|
1588
|
+
condensed; the general what-a-control-is prose lives in "About Clip". */}
|
|
1589
|
+
{drawEye && (
|
|
1590
|
+
<p className="vi-input-callout" role="note">
|
|
1591
|
+
This generator is driven BY the control still — a STATIC pose/depth/sketch
|
|
1592
|
+
anchor repeated across every frame. A source clip cannot ride along (the
|
|
1593
|
+
control channel takes one input and the clip would win).
|
|
1594
|
+
</p>
|
|
1595
|
+
)}
|
|
1596
|
+
<p className="vi-comfy-hint" role="note" style={{ marginTop: 0 }}>
|
|
1597
|
+
Both-or-neither: set the image and its kind together.
|
|
1598
|
+
</p>
|
|
1599
|
+
<div
|
|
1600
|
+
style={{ display: "flex", gap: "0.75rem", flexWrap: "wrap", alignItems: "flex-end", marginTop: "0.4rem" }}
|
|
1601
|
+
>
|
|
1602
|
+
{controlImage ? (
|
|
1603
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.2rem" }}>
|
|
1604
|
+
<img
|
|
1605
|
+
src={mediaBytesUrl(controlImage.uri)}
|
|
1606
|
+
alt="composition control"
|
|
1607
|
+
style={{
|
|
1608
|
+
width: "5.5rem",
|
|
1609
|
+
height: "5.5rem",
|
|
1610
|
+
objectFit: "cover",
|
|
1611
|
+
borderRadius: "0.4rem",
|
|
1612
|
+
background: "#000",
|
|
1613
|
+
border: "1px solid var(--vi-border)",
|
|
1614
|
+
}}
|
|
1615
|
+
/>
|
|
1616
|
+
<button
|
|
1617
|
+
type="button"
|
|
1618
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
1619
|
+
onClick={() => setControlImage(null)}
|
|
1620
|
+
>
|
|
1621
|
+
Remove
|
|
1622
|
+
</button>
|
|
1623
|
+
</div>
|
|
1624
|
+
) : (
|
|
1625
|
+
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
|
|
1626
|
+
<label className="vi-btn vi-btn-ghost vi-file-label">
|
|
1627
|
+
{imageBusy ? "Working…" : "Upload control image"}
|
|
1628
|
+
<input
|
|
1629
|
+
type="file"
|
|
1630
|
+
accept="image/*"
|
|
1631
|
+
className="vi-file-input"
|
|
1632
|
+
disabled={imageBusy}
|
|
1633
|
+
onChange={(e) => {
|
|
1634
|
+
const f = e.target.files?.[0];
|
|
1635
|
+
if (f) void onPickImageFile(f, "control");
|
|
1636
|
+
e.target.value = "";
|
|
1637
|
+
}}
|
|
1638
|
+
/>
|
|
1639
|
+
</label>
|
|
1640
|
+
<button
|
|
1641
|
+
type="button"
|
|
1642
|
+
className="vi-btn vi-btn-ghost"
|
|
1643
|
+
onClick={() => setShowControlLib((v) => !v)}
|
|
1644
|
+
aria-expanded={showControlLib}
|
|
1645
|
+
>
|
|
1646
|
+
{showControlLib ? "Hide library" : "Pick from library"}
|
|
1647
|
+
</button>
|
|
1648
|
+
</div>
|
|
1649
|
+
)}
|
|
1650
|
+
<label className="vi-comfy-field" style={{ maxWidth: "10rem" }}>
|
|
1651
|
+
<span className="vi-comfy-label">Control kind</span>
|
|
1652
|
+
<select
|
|
1653
|
+
className="vi-knob-input"
|
|
1654
|
+
value={controlKind}
|
|
1655
|
+
onChange={(e) => setControlKind(e.target.value as ControlKind)}
|
|
1656
|
+
>
|
|
1657
|
+
<option value="">— none —</option>
|
|
1658
|
+
<option value="pose">pose</option>
|
|
1659
|
+
<option value="depth">depth</option>
|
|
1660
|
+
<option value="sketch">sketch</option>
|
|
1661
|
+
</select>
|
|
1662
|
+
</label>
|
|
1663
|
+
</div>
|
|
1664
|
+
{controlHalfSet && (
|
|
1665
|
+
<p className="vi-comfy-hint" role="alert" style={{ marginTop: "0.3rem" }}>
|
|
1666
|
+
Composition control is both-or-neither — add a control image AND a kind, or clear both.
|
|
1667
|
+
</p>
|
|
1668
|
+
)}
|
|
1669
|
+
{showControlLib && !controlImage && (
|
|
1670
|
+
<LibraryImageGrid
|
|
1671
|
+
images={libraryImages}
|
|
1672
|
+
onPick={(ref) => {
|
|
1673
|
+
setControlImage(ref);
|
|
1674
|
+
setShowControlLib(false);
|
|
1675
|
+
}}
|
|
1676
|
+
/>
|
|
1677
|
+
)}
|
|
1678
|
+
</section>
|
|
1679
|
+
);
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// ── source-clip input (hero = dominant/accented; inline = subdued; inline-alt =
|
|
1683
|
+
// the "or extend a clip" alternative under a hero start image) ──
|
|
1684
|
+
function renderSource(variant: "hero" | "inline" | "inline-alt") {
|
|
1685
|
+
const isHero = variant === "hero";
|
|
1686
|
+
const isAlt = variant === "inline-alt";
|
|
1687
|
+
const drawEye = isHero && sourceAttention;
|
|
1688
|
+
const label = isAlt
|
|
1689
|
+
? "…or use a source clip (extend from its last frame)"
|
|
1690
|
+
: isHero
|
|
1691
|
+
? "Source clip — required (the clip to repaint)"
|
|
1692
|
+
: isV2V
|
|
1693
|
+
? "Source clip — required"
|
|
1694
|
+
: "Source clip (optional — extend from its last frame)";
|
|
1695
|
+
return (
|
|
1696
|
+
<section aria-label="Source clip" style={{ marginTop: isHero ? 0 : "0.9rem" }}>
|
|
1697
|
+
<p
|
|
1698
|
+
className="vi-comfy-label"
|
|
1699
|
+
style={{ marginBottom: "0.35rem", opacity: isHero ? 1 : 0.75 }}
|
|
1700
|
+
>
|
|
1701
|
+
{label}
|
|
1702
|
+
</p>
|
|
1703
|
+
{drawEye && (
|
|
1704
|
+
<p className="vi-input-callout" role="note">
|
|
1705
|
+
This generator needs a source clip to repaint — pick one below (or send a clip to the
|
|
1706
|
+
studio from the Library / Movie Maker).
|
|
1707
|
+
</p>
|
|
1708
|
+
)}
|
|
1709
|
+
<div className={drawEye ? "vi-input-required" : undefined}>
|
|
1710
|
+
{source ? (
|
|
1711
|
+
<>
|
|
1712
|
+
<div className="vi-studio-src-selected">
|
|
1713
|
+
<SourceThumb
|
|
1714
|
+
uri={source.uri}
|
|
1715
|
+
name={source.asset_id.slice(0, 8)}
|
|
1716
|
+
playing={sourcePreview}
|
|
1717
|
+
onToggle={() => setSourcePreview((v) => !v)}
|
|
1718
|
+
/>
|
|
1719
|
+
<p className="vi-comfy-hint" role="note" style={{ margin: 0 }}>
|
|
1720
|
+
<strong>{isV2V ? "Restyling" : "Extending"}</strong>{" "}
|
|
1721
|
+
<code>{source.asset_id.slice(0, 8)}</code>
|
|
1722
|
+
{source.width != null && source.height != null
|
|
1723
|
+
? ` · ${source.width}×${source.height}`
|
|
1724
|
+
: ""}
|
|
1725
|
+
{source.duration_s != null ? ` · ${Math.round(source.duration_s)}s` : ""}
|
|
1726
|
+
</p>
|
|
1727
|
+
</div>
|
|
1728
|
+
{/* Subject-clip switcher (operator ask 2026-07-12): "there isn't a way to
|
|
1729
|
+
actually switch it out" — this row is the ONE concrete front door to
|
|
1730
|
+
all three swap paths the operator named. It CONSOLIDATES the 2026-07-10
|
|
1731
|
+
"Change" button (clear + reopen the library picker, kept as "From
|
|
1732
|
+
library" below — removed from here, not duplicated) plus the two new
|
|
1733
|
+
paths, alongside the plain unstage. */}
|
|
1734
|
+
<div className="vi-studio-src-actions" role="group" aria-label="Change subject clip">
|
|
1735
|
+
<button
|
|
1736
|
+
type="button"
|
|
1737
|
+
className="vi-btn vi-btn-sm"
|
|
1738
|
+
onClick={() => {
|
|
1739
|
+
setSource(null);
|
|
1740
|
+
setSourcePreview(false);
|
|
1741
|
+
setShowSourcePicker(true);
|
|
1742
|
+
}}
|
|
1743
|
+
>
|
|
1744
|
+
⇄ From library
|
|
1745
|
+
</button>
|
|
1746
|
+
<label className="vi-btn vi-btn-sm vi-file-label">
|
|
1747
|
+
{imageBusy ? "Working…" : "⇪ Upload video"}
|
|
1748
|
+
<input
|
|
1749
|
+
type="file"
|
|
1750
|
+
accept="video/*"
|
|
1751
|
+
className="vi-file-input"
|
|
1752
|
+
disabled={imageBusy}
|
|
1753
|
+
onChange={(e) => {
|
|
1754
|
+
const f = e.target.files?.[0];
|
|
1755
|
+
if (f) void onPickVideoFile(f);
|
|
1756
|
+
e.target.value = "";
|
|
1757
|
+
}}
|
|
1758
|
+
/>
|
|
1759
|
+
</label>
|
|
1760
|
+
<button type="button" className="vi-btn vi-btn-sm" onClick={onGenerateNewSubject}>
|
|
1761
|
+
✦ Generate new
|
|
1762
|
+
</button>
|
|
1763
|
+
<button
|
|
1764
|
+
type="button"
|
|
1765
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
1766
|
+
onClick={() => {
|
|
1767
|
+
setSource(null);
|
|
1768
|
+
setSourcePreview(false);
|
|
1769
|
+
}}
|
|
1770
|
+
>
|
|
1771
|
+
✕ Clear
|
|
1772
|
+
</button>
|
|
1773
|
+
</div>
|
|
1774
|
+
</>
|
|
1775
|
+
) : (
|
|
1776
|
+
<div className="vi-studio-src-actions" role="group" aria-label="Add subject clip">
|
|
1777
|
+
<button
|
|
1778
|
+
type="button"
|
|
1779
|
+
className={isHero ? "vi-btn vi-btn-accent" : "vi-btn vi-btn-ghost"}
|
|
1780
|
+
onClick={() => setShowSourcePicker((v) => !v)}
|
|
1781
|
+
aria-expanded={showSourcePicker}
|
|
1782
|
+
>
|
|
1783
|
+
{showSourcePicker ? "Hide clips" : `⇄ From library (${sourceClips.length})`}
|
|
1784
|
+
</button>
|
|
1785
|
+
<label
|
|
1786
|
+
className={`${isHero ? "vi-btn vi-btn-accent" : "vi-btn vi-btn-ghost"} vi-file-label`}
|
|
1787
|
+
>
|
|
1788
|
+
{imageBusy ? "Working…" : "⇪ Upload video"}
|
|
1789
|
+
<input
|
|
1790
|
+
type="file"
|
|
1791
|
+
accept="video/*"
|
|
1792
|
+
className="vi-file-input"
|
|
1793
|
+
disabled={imageBusy}
|
|
1794
|
+
onChange={(e) => {
|
|
1795
|
+
const f = e.target.files?.[0];
|
|
1796
|
+
if (f) void onPickVideoFile(f);
|
|
1797
|
+
e.target.value = "";
|
|
1798
|
+
}}
|
|
1799
|
+
/>
|
|
1800
|
+
</label>
|
|
1801
|
+
<button type="button" className="vi-btn vi-btn-ghost" onClick={onGenerateNewSubject}>
|
|
1802
|
+
✦ Generate new clip
|
|
1803
|
+
</button>
|
|
1804
|
+
</div>
|
|
1805
|
+
)}
|
|
1806
|
+
{imageError && (
|
|
1807
|
+
<p className="vi-error" role="alert" style={{ marginTop: "0.4rem" }}>
|
|
1808
|
+
{imageError}
|
|
1809
|
+
</p>
|
|
1810
|
+
)}
|
|
1811
|
+
{showSourcePicker && !source && (
|
|
1812
|
+
<ul
|
|
1813
|
+
style={{
|
|
1814
|
+
listStyle: "none",
|
|
1815
|
+
margin: "0.5rem 0 0",
|
|
1816
|
+
padding: 0,
|
|
1817
|
+
maxHeight: "14rem",
|
|
1818
|
+
overflowY: "auto",
|
|
1819
|
+
}}
|
|
1820
|
+
>
|
|
1821
|
+
{sourceClips.length === 0 ? (
|
|
1822
|
+
<li className="vi-comfy-hint">No playable studio clips yet.</li>
|
|
1823
|
+
) : (
|
|
1824
|
+
sourceClips.map((c) => {
|
|
1825
|
+
// Restructured row (mirrors the Library's LibraryRow): a poster/preview
|
|
1826
|
+
// thumbnail + metadata + a DISTINCT select affordance. The row is no
|
|
1827
|
+
// longer one big <button> — a playable <video> can't nest in a button.
|
|
1828
|
+
const ref = clipToMediaRef(c);
|
|
1829
|
+
const w = c.output?.width;
|
|
1830
|
+
const h = c.output?.height;
|
|
1831
|
+
const dur = c.output?.duration_s;
|
|
1832
|
+
const isPreviewing = previewId === c.job_id;
|
|
1833
|
+
const togglePreview = () =>
|
|
1834
|
+
setPreviewId((cur) => (cur === c.job_id ? null : c.job_id));
|
|
1835
|
+
return (
|
|
1836
|
+
<li key={c.job_id} className="vi-studio-src-row">
|
|
1837
|
+
{ref ? (
|
|
1838
|
+
<SourceThumb
|
|
1839
|
+
uri={ref.uri}
|
|
1840
|
+
name={shortId(c.job_id)}
|
|
1841
|
+
playing={isPreviewing}
|
|
1842
|
+
onToggle={togglePreview}
|
|
1843
|
+
/>
|
|
1844
|
+
) : (
|
|
1845
|
+
<div className="vi-studio-src-thumb vi-lib-placeholder" aria-hidden>
|
|
1846
|
+
<span>clip</span>
|
|
1847
|
+
</div>
|
|
1848
|
+
)}
|
|
1849
|
+
<div className="vi-studio-src-body">
|
|
1850
|
+
<div className="vi-studio-src-meta" title={c.job_id}>
|
|
1851
|
+
<code>{shortId(c.job_id)}</code>
|
|
1852
|
+
{w != null && h != null ? (
|
|
1853
|
+
<span>
|
|
1854
|
+
{w}×{h}
|
|
1855
|
+
</span>
|
|
1856
|
+
) : null}
|
|
1857
|
+
{dur != null ? <span>{Math.round(dur)}s</span> : null}
|
|
1858
|
+
</div>
|
|
1859
|
+
<div className="vi-studio-src-actions">
|
|
1860
|
+
<button
|
|
1861
|
+
type="button"
|
|
1862
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
1863
|
+
onClick={togglePreview}
|
|
1864
|
+
disabled={!ref}
|
|
1865
|
+
aria-pressed={isPreviewing}
|
|
1866
|
+
>
|
|
1867
|
+
{isPreviewing ? "◾ Stop" : "▶ Preview"}
|
|
1868
|
+
</button>
|
|
1869
|
+
<button
|
|
1870
|
+
type="button"
|
|
1871
|
+
className="vi-btn vi-btn-sm vi-btn-accent"
|
|
1872
|
+
onClick={() => {
|
|
1873
|
+
if (ref) setSource(ref);
|
|
1874
|
+
setShowSourcePicker(false);
|
|
1875
|
+
setPreviewId(null);
|
|
1876
|
+
// A manual library pick takes back control from any
|
|
1877
|
+
// armed/in-flight "generate new subject" wait.
|
|
1878
|
+
setPendingSubjectStage(false);
|
|
1879
|
+
setAwaitingSubjectJobId(null);
|
|
1880
|
+
}}
|
|
1881
|
+
disabled={!ref}
|
|
1882
|
+
title={
|
|
1883
|
+
ref
|
|
1884
|
+
? isV2V
|
|
1885
|
+
? "Use this clip as the restyle source"
|
|
1886
|
+
: "Extend from this clip's last frame"
|
|
1887
|
+
: "This clip has no streamable output yet"
|
|
1888
|
+
}
|
|
1889
|
+
>
|
|
1890
|
+
{isV2V ? "Use · restyle" : "Use · extend"}
|
|
1891
|
+
</button>
|
|
1892
|
+
</div>
|
|
1893
|
+
</div>
|
|
1894
|
+
</li>
|
|
1895
|
+
);
|
|
1896
|
+
})
|
|
1897
|
+
)}
|
|
1898
|
+
</ul>
|
|
1899
|
+
)}
|
|
1900
|
+
</div>
|
|
1901
|
+
</section>
|
|
1902
|
+
);
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// Round 8: the Movie .vi-knob grid renders into the LEFT-SIDEBAR "Settings" tab (via a
|
|
1906
|
+
// studio-local portal into `settingsHost`), NOT the center — folding the former inline
|
|
1907
|
+
// knob column into the sidebar (movie-shape canonical). Knob STATE stays in this
|
|
1908
|
+
// component; the portal only relocates where the inputs render.
|
|
1909
|
+
// ── PRESET HONESTY (2026-07-29): a preset offered = a preset whose capability is
|
|
1910
|
+
// servable on this fleet, and an UNPROVEN capability says so on the row.
|
|
1911
|
+
//
|
|
1912
|
+
// The curated studio presets (GET /video/studio/presets) are INTENT; the ratified
|
|
1913
|
+
// render table (GET /video/render/presets) is MEASUREMENT. Joining them per capability
|
|
1914
|
+
// is what turns the dropdown from "eight things we wrote down" into "the things that
|
|
1915
|
+
// will come back as pixels, and which of them have". Nothing is hidden: an option that
|
|
1916
|
+
// cannot be picked right now renders DISABLED carrying the backend's own reason, so
|
|
1917
|
+
// the fleet's shape stays visible instead of silently shrinking the menu.
|
|
1918
|
+
function presetOption(p: StudioPreset) {
|
|
1919
|
+
const o = renderLoaded ? offerFor(p.capability) : undefined;
|
|
1920
|
+
const unproven = o?.servable === true && o.proven === false;
|
|
1921
|
+
const label = `${p.name}${unproven ? " · unproven" : ""}`;
|
|
1922
|
+
const disabled = !!o && !o.offered;
|
|
1923
|
+
return (
|
|
1924
|
+
<option key={p.id} value={p.id} disabled={disabled} title={o?.reason || p.description || ""}>
|
|
1925
|
+
{label}
|
|
1926
|
+
{disabled ? " — unavailable" : ""}
|
|
1927
|
+
</option>
|
|
1928
|
+
);
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
const knobGrid = (
|
|
1932
|
+
<div className="vi-studio-knobs">
|
|
1933
|
+
{/* ABOUT — the condensed what-is-this / how-to prose (k89), collapsed at the
|
|
1934
|
+
top of the Settings-tab content. Absorbs the descriptive halves of the old
|
|
1935
|
+
inline callouts; the conditional required-input warnings stay on the form. */}
|
|
1936
|
+
<div className="vi-studio-knob-wide" style={{ flex: "1 1 100%" }}>
|
|
1937
|
+
<AboutExpander title="About Clip">
|
|
1938
|
+
<p>
|
|
1939
|
+
Generates ONE studio clip from a prompt plus whatever inputs the chosen
|
|
1940
|
+
generator needs.
|
|
1941
|
+
</p>
|
|
1942
|
+
<p>
|
|
1943
|
+
Pick a template or capability here and the form morphs to that shape:
|
|
1944
|
+
text→video needs nothing, image→video a start image (or a source clip to
|
|
1945
|
+
extend), restyle a source clip, identity lock 1–4 reference images, and
|
|
1946
|
+
pose/structure a control still.
|
|
1947
|
+
</p>
|
|
1948
|
+
<p>
|
|
1949
|
+
A missing required input is flagged on the card and gates Generate until
|
|
1950
|
+
you add it.
|
|
1951
|
+
</p>
|
|
1952
|
+
<p>
|
|
1953
|
+
Finished clips play in the viewer above and land in the Session Library;
|
|
1954
|
+
the Tester at the bottom sweeps one prompt across every model.
|
|
1955
|
+
</p>
|
|
1956
|
+
</AboutExpander>
|
|
1957
|
+
</div>
|
|
1958
|
+
{/* TEMPLATE dropdown — the tier chooser, mirroring Movie's right-rail PRESET
|
|
1959
|
+
dropdown. Picking one = clicking a tier card: sets capability/shape + prefills
|
|
1960
|
+
the knobs below. (The center cards are retired — this is the one chooser.) */}
|
|
1961
|
+
<div className="vi-knob vi-studio-knob-wide">
|
|
1962
|
+
<label htmlFor="vi-studio-template">Template</label>
|
|
1963
|
+
<select
|
|
1964
|
+
id="vi-studio-template"
|
|
1965
|
+
className="vi-knob-select"
|
|
1966
|
+
value={presetId}
|
|
1967
|
+
onChange={(e) => (e.target.value ? onPickPreset(e.target.value) : setPresetId(""))}
|
|
1968
|
+
>
|
|
1969
|
+
<option value="">Custom — no template</option>
|
|
1970
|
+
{generalPresets.length > 0 && (
|
|
1971
|
+
<optgroup label="Tiers">{generalPresets.map((p) => presetOption(p))}</optgroup>
|
|
1972
|
+
)}
|
|
1973
|
+
{lockPresets.length > 0 && (
|
|
1974
|
+
<optgroup label="Identity lock & continuity">
|
|
1975
|
+
{lockPresets.map((p) => presetOption(p))}
|
|
1976
|
+
</optgroup>
|
|
1977
|
+
)}
|
|
1978
|
+
{synthPresets.length > 0 && (
|
|
1979
|
+
<optgroup label="Synthetic previews">{synthPresets.map((p) => presetOption(p))}</optgroup>
|
|
1980
|
+
)}
|
|
1981
|
+
</select>
|
|
1982
|
+
<span className="vi-knob-hint">
|
|
1983
|
+
A template sets the WORKFLOW — capability, geometry, cadence, budget — and slots
|
|
1984
|
+
it around what you have already written and attached. Your prompt, negative,
|
|
1985
|
+
identity, images and any seed you chose are never overwritten; an empty prompt
|
|
1986
|
+
box is the only thing a template's example text fills.
|
|
1987
|
+
{renderLoaded ? " Rows marked “unproven” have a ratified path that has not yet produced a clip on this fleet." : ""}
|
|
1988
|
+
</span>
|
|
1989
|
+
</div>
|
|
1990
|
+
{/* PROJECT — choose an existing name or free-type a new one (Movie's "PROJECT NAME
|
|
1991
|
+
(OPTIONAL)" idiom). Threaded into the enqueue as `project`; blank = auto-named. */}
|
|
1992
|
+
<div className="vi-knob vi-studio-knob-wide">
|
|
1993
|
+
<label htmlFor="vi-studio-project">Project name (optional)</label>
|
|
1994
|
+
<input
|
|
1995
|
+
id="vi-studio-project"
|
|
1996
|
+
className="vi-knob-input"
|
|
1997
|
+
type="text"
|
|
1998
|
+
list="vi-studio-project-list"
|
|
1999
|
+
value={project}
|
|
2000
|
+
placeholder="auto-named — or pick / type a project"
|
|
2001
|
+
onChange={(e) => setProject(e.target.value)}
|
|
2002
|
+
/>
|
|
2003
|
+
<datalist id="vi-studio-project-list">
|
|
2004
|
+
{knownProjects.map((name) => (
|
|
2005
|
+
<option key={name} value={name} />
|
|
2006
|
+
))}
|
|
2007
|
+
</datalist>
|
|
2008
|
+
<span className="vi-knob-hint">
|
|
2009
|
+
Choose an existing project or type a new name to create it. Blank keeps the
|
|
2010
|
+
auto-named default.
|
|
2011
|
+
</span>
|
|
2012
|
+
</div>
|
|
2013
|
+
<div className="vi-knob vi-studio-knob-wide">
|
|
2014
|
+
<label htmlFor="vi-studio-cap">Capability</label>
|
|
2015
|
+
<select
|
|
2016
|
+
id="vi-studio-cap"
|
|
2017
|
+
className="vi-knob-select"
|
|
2018
|
+
value={capability}
|
|
2019
|
+
onChange={(e) => onPickCapability(e.target.value as Capability)}
|
|
2020
|
+
>
|
|
2021
|
+
{/* SOURCED FROM THE SERVABLE SET (2026-07-29), not from a hand-curated union.
|
|
2022
|
+
Every row here is a capability at least one RATIFIED preset covers; a row
|
|
2023
|
+
that the fleet cannot serve, or that the inputs you have already attached
|
|
2024
|
+
rule out, renders DISABLED with the backend's own reason as its tooltip
|
|
2025
|
+
rather than vanishing — the same disabled-with-honest-title idiom the
|
|
2026
|
+
movie start switcher uses for "start from a clip".
|
|
2027
|
+
|
|
2028
|
+
id_lock stays non-selectable-by-hand: it is set by an identity-lock
|
|
2029
|
+
TEMPLATE or by attaching a profile/references. It must still RENDER while
|
|
2030
|
+
active — this is a controlled <select>, and a value with no matching
|
|
2031
|
+
<option> shows blank. */}
|
|
2032
|
+
{capOffers.map((o) => {
|
|
2033
|
+
const viaTemplate = o.capability === "id_lock";
|
|
2034
|
+
const disabled = !o.offered || (viaTemplate && !isIdLock);
|
|
2035
|
+
if (viaTemplate && !isIdLock && !o.servable) return null;
|
|
2036
|
+
const suffix = !o.servable
|
|
2037
|
+
? " — not on this fleet"
|
|
2038
|
+
: viaTemplate
|
|
2039
|
+
? " — via template"
|
|
2040
|
+
: !o.offered
|
|
2041
|
+
? " — incompatible with your inputs"
|
|
2042
|
+
: o.proven === false
|
|
2043
|
+
? " · unproven"
|
|
2044
|
+
: "";
|
|
2045
|
+
return (
|
|
2046
|
+
<option
|
|
2047
|
+
key={o.capability}
|
|
2048
|
+
value={o.capability}
|
|
2049
|
+
disabled={disabled}
|
|
2050
|
+
title={o.reason || undefined}
|
|
2051
|
+
>
|
|
2052
|
+
{o.label}
|
|
2053
|
+
{suffix}
|
|
2054
|
+
</option>
|
|
2055
|
+
);
|
|
2056
|
+
})}
|
|
2057
|
+
{/* THE OTHER HALF OF THE ANSWER, and the reason /video/render/presets publishes
|
|
2058
|
+
it: every capability the enum declares that NO ratified preset covers, with
|
|
2059
|
+
the MEASURED blocker as its tooltip. Shown disabled rather than omitted so a
|
|
2060
|
+
user learns "studio cannot render 'keyframe' on this fleet: … no end-frame
|
|
2061
|
+
input exists in the spine" at DISCOVERY time instead of after a render. The
|
|
2062
|
+
wording is the registry's, verbatim. */}
|
|
2063
|
+
{renderUnavailable.length > 0 && (
|
|
2064
|
+
<optgroup label="Not renderable on this fleet">
|
|
2065
|
+
{renderUnavailable.map((u) => (
|
|
2066
|
+
<option
|
|
2067
|
+
key={u.capability}
|
|
2068
|
+
value={u.capability}
|
|
2069
|
+
disabled
|
|
2070
|
+
title={u.refusal || u.reason || undefined}
|
|
2071
|
+
>
|
|
2072
|
+
{capabilityLabel(u.capability)} — {u.reason ? u.reason.slice(0, 90) : "no preset covers it"}
|
|
2073
|
+
</option>
|
|
2074
|
+
))}
|
|
2075
|
+
</optgroup>
|
|
2076
|
+
)}
|
|
2077
|
+
</select>
|
|
2078
|
+
<span className="vi-knob-hint">
|
|
2079
|
+
{renderLoaded
|
|
2080
|
+
? `What this fleet can render today${renderBox ? ` on ${renderBox}` : ""}. Greyed-out rows say why — hover for the measured reason.`
|
|
2081
|
+
: "Loading what this fleet can render…"}
|
|
2082
|
+
</span>
|
|
2083
|
+
</div>
|
|
2084
|
+
<div className="vi-knob">
|
|
2085
|
+
<label htmlFor="vi-studio-w">width (px)</label>
|
|
2086
|
+
<input
|
|
2087
|
+
id="vi-studio-w"
|
|
2088
|
+
className="vi-knob-input"
|
|
2089
|
+
type="number"
|
|
2090
|
+
min={64}
|
|
2091
|
+
step={8}
|
|
2092
|
+
value={width}
|
|
2093
|
+
disabled={isV2V}
|
|
2094
|
+
title={isV2V ? geomLockTitle : geomEnvelopeTitle}
|
|
2095
|
+
onChange={(e) => {
|
|
2096
|
+
geomTouched.current = true;
|
|
2097
|
+
setWidth(Number(e.target.value) || 0);
|
|
2098
|
+
}}
|
|
2099
|
+
/>
|
|
2100
|
+
</div>
|
|
2101
|
+
<div className="vi-knob">
|
|
2102
|
+
<label htmlFor="vi-studio-h">height (px)</label>
|
|
2103
|
+
<input
|
|
2104
|
+
id="vi-studio-h"
|
|
2105
|
+
className="vi-knob-input"
|
|
2106
|
+
type="number"
|
|
2107
|
+
min={64}
|
|
2108
|
+
step={8}
|
|
2109
|
+
value={height}
|
|
2110
|
+
disabled={isV2V}
|
|
2111
|
+
title={isV2V ? geomLockTitle : geomEnvelopeTitle}
|
|
2112
|
+
onChange={(e) => {
|
|
2113
|
+
geomTouched.current = true;
|
|
2114
|
+
setHeight(Number(e.target.value) || 0);
|
|
2115
|
+
}}
|
|
2116
|
+
/>
|
|
2117
|
+
</div>
|
|
2118
|
+
<div className="vi-knob">
|
|
2119
|
+
<label htmlFor="vi-studio-fps">fps</label>
|
|
2120
|
+
<input
|
|
2121
|
+
id="vi-studio-fps"
|
|
2122
|
+
className="vi-knob-input"
|
|
2123
|
+
type="number"
|
|
2124
|
+
min={1}
|
|
2125
|
+
max={60}
|
|
2126
|
+
value={fps}
|
|
2127
|
+
disabled={isV2V}
|
|
2128
|
+
title={isV2V ? geomLockTitle : geomEnvelopeTitle}
|
|
2129
|
+
onChange={(e) => {
|
|
2130
|
+
geomTouched.current = true;
|
|
2131
|
+
setFps(Number(e.target.value) || 0);
|
|
2132
|
+
}}
|
|
2133
|
+
/>
|
|
2134
|
+
{geomOutOfEnvelope && !isV2V && (
|
|
2135
|
+
<span className="vi-knob-flag">
|
|
2136
|
+
Outside the ratified envelope{capGeometry ? ` (${capGeometry.width}×${capGeometry.height})` : ""} —
|
|
2137
|
+
this geometry has no proven binding for {CAP_LABELS[capability]} and the router may refuse it.
|
|
2138
|
+
</span>
|
|
2139
|
+
)}
|
|
2140
|
+
</div>
|
|
2141
|
+
<div className="vi-knob">
|
|
2142
|
+
<label htmlFor="vi-studio-seed">seed</label>
|
|
2143
|
+
<input
|
|
2144
|
+
id="vi-studio-seed"
|
|
2145
|
+
className="vi-knob-input"
|
|
2146
|
+
type="number"
|
|
2147
|
+
min={0}
|
|
2148
|
+
value={seed}
|
|
2149
|
+
onChange={(e) => setSeed(Number(e.target.value) || 0)}
|
|
2150
|
+
/>
|
|
2151
|
+
</div>
|
|
2152
|
+
<div className="vi-knob">
|
|
2153
|
+
<label htmlFor="vi-studio-vram">vram budget (GB)</label>
|
|
2154
|
+
<input
|
|
2155
|
+
id="vi-studio-vram"
|
|
2156
|
+
className="vi-knob-input"
|
|
2157
|
+
type="number"
|
|
2158
|
+
min={0}
|
|
2159
|
+
step={0.5}
|
|
2160
|
+
value={vramBudget}
|
|
2161
|
+
placeholder="route default"
|
|
2162
|
+
onChange={(e) => {
|
|
2163
|
+
// Marks the field OPERATOR-OWNED: from here on a preset or capability change
|
|
2164
|
+
// fills nothing over it. Clearing the field hands it back to the presets.
|
|
2165
|
+
budgetTouched.current = e.target.value.trim() !== "";
|
|
2166
|
+
setVramBudget(e.target.value);
|
|
2167
|
+
}}
|
|
2168
|
+
/>
|
|
2169
|
+
<span className="vi-knob-hint">
|
|
2170
|
+
{capFloorGb != null
|
|
2171
|
+
? `Blank = autofit to the card. ${CAP_LABELS[capability]} needs at least ${capFloorGb} GB to bind a real model — measured, not guessed.`
|
|
2172
|
+
: "Blank = route default (sub-real binds synthetic)."}
|
|
2173
|
+
</span>
|
|
2174
|
+
</div>
|
|
2175
|
+
<div className="vi-knob">
|
|
2176
|
+
<label htmlFor="vi-studio-steps">steps</label>
|
|
2177
|
+
<input
|
|
2178
|
+
id="vi-studio-steps"
|
|
2179
|
+
className="vi-knob-input"
|
|
2180
|
+
type="number"
|
|
2181
|
+
min={1}
|
|
2182
|
+
max={100}
|
|
2183
|
+
value={steps}
|
|
2184
|
+
placeholder="model default"
|
|
2185
|
+
onChange={(e) => setSteps(e.target.value)}
|
|
2186
|
+
/>
|
|
2187
|
+
</div>
|
|
2188
|
+
<div className="vi-knob">
|
|
2189
|
+
<label htmlFor="vi-studio-cfg">cfg</label>
|
|
2190
|
+
<input
|
|
2191
|
+
id="vi-studio-cfg"
|
|
2192
|
+
className="vi-knob-input"
|
|
2193
|
+
type="number"
|
|
2194
|
+
min={0}
|
|
2195
|
+
max={20}
|
|
2196
|
+
step={0.5}
|
|
2197
|
+
value={cfg}
|
|
2198
|
+
placeholder="model default"
|
|
2199
|
+
onChange={(e) => setCfg(e.target.value)}
|
|
2200
|
+
/>
|
|
2201
|
+
</div>
|
|
2202
|
+
{/* MODEL PIN — FILTERED BY CAPABILITY (2026-07-29).
|
|
2203
|
+
It used to offer STUDIO_MODEL_IDS: a static, unfiltered mirror of a backend seed
|
|
2204
|
+
file. Nothing in it knew which models can serve the capability you had chosen,
|
|
2205
|
+
so pinning wan2.1-t2v-1.3b under capability=v2v was a first-class menu choice
|
|
2206
|
+
that could only fail at render time. The menu is now the models that appear in a
|
|
2207
|
+
RATIFIED preset for the active capability.
|
|
2208
|
+
|
|
2209
|
+
THE ESCAPE HATCH IS DELIBERATE. "Show every studio model" keeps the whole list
|
|
2210
|
+
reachable — an operator may legitimately want to pin off-menu (to prove a new
|
|
2211
|
+
binding, or to reproduce an old job) — and every off-menu row is LABELLED
|
|
2212
|
+
unverified rather than quietly presented as equivalent. An unroutable pin still
|
|
2213
|
+
comes back as errors-as-data on the job; this only changes what we recommend. */}
|
|
2214
|
+
<div className="vi-knob vi-studio-knob-wide">
|
|
2215
|
+
<label htmlFor="vi-studio-model">model pin</label>
|
|
2216
|
+
<select
|
|
2217
|
+
id="vi-studio-model"
|
|
2218
|
+
className="vi-knob-select"
|
|
2219
|
+
value={modelId}
|
|
2220
|
+
onChange={(e) => setModelId(e.target.value)}
|
|
2221
|
+
>
|
|
2222
|
+
<option value="">Auto — router picks by budget + resolution</option>
|
|
2223
|
+
{capModels.length > 0 && (
|
|
2224
|
+
<optgroup label={`Ratified for ${CAP_LABELS[capability]}`}>
|
|
2225
|
+
{capModels.map((id) => (
|
|
2226
|
+
<option key={id} value={id}>
|
|
2227
|
+
{id}
|
|
2228
|
+
</option>
|
|
2229
|
+
))}
|
|
2230
|
+
</optgroup>
|
|
2231
|
+
)}
|
|
2232
|
+
{(showAllModels || !renderLoaded || modelOffMenu) && (
|
|
2233
|
+
<optgroup label={renderLoaded ? "Other studio models — unverified for this capability" : "Studio models"}>
|
|
2234
|
+
{STUDIO_MODEL_IDS.filter((id) => !capModels.includes(id)).map((id) => (
|
|
2235
|
+
<option key={id} value={id}>
|
|
2236
|
+
{id}
|
|
2237
|
+
{renderLoaded ? " · unverified" : ""}
|
|
2238
|
+
</option>
|
|
2239
|
+
))}
|
|
2240
|
+
</optgroup>
|
|
2241
|
+
)}
|
|
2242
|
+
</select>
|
|
2243
|
+
{renderLoaded && (
|
|
2244
|
+
<label className="vi-knob-hint" style={{ display: "flex", gap: "0.35rem", alignItems: "center" }}>
|
|
2245
|
+
<input
|
|
2246
|
+
type="checkbox"
|
|
2247
|
+
checked={showAllModels}
|
|
2248
|
+
onChange={(e) => setShowAllModels(e.target.checked)}
|
|
2249
|
+
/>
|
|
2250
|
+
Advanced — show every studio model (off-menu pins are unverified for this capability)
|
|
2251
|
+
</label>
|
|
2252
|
+
)}
|
|
2253
|
+
{modelOffMenu && (
|
|
2254
|
+
<span className="vi-knob-flag">
|
|
2255
|
+
{modelId} has no ratified preset for {CAP_LABELS[capability]} — the router may
|
|
2256
|
+
refuse the pin, and it will say so on the job rather than fall back silently.
|
|
2257
|
+
</span>
|
|
2258
|
+
)}
|
|
2259
|
+
</div>
|
|
2260
|
+
</div>
|
|
2261
|
+
);
|
|
2262
|
+
|
|
2263
|
+
return (
|
|
2264
|
+
<div className="vi-studio-generate">
|
|
2265
|
+
{/* B2 mode switch: Clip (existing surface, default, unchanged) | Cinema (movie).
|
|
2266
|
+
Rendered ONLY on the standalone /studio-clips mount (no lockedSurfaceMode). When
|
|
2267
|
+
the top-level Generate tab locks the choice (Clip / Cinema tabs), this switcher is
|
|
2268
|
+
hidden — the tab IS the choice. The "movie" surfaceMode value is unchanged (label
|
|
2269
|
+
reads "Cinema"; the wire/tester value stays "movie"). */}
|
|
2270
|
+
{!lockedSurfaceMode && (
|
|
2271
|
+
<div
|
|
2272
|
+
role="tablist"
|
|
2273
|
+
aria-label="Studio generate mode"
|
|
2274
|
+
style={{ display: "flex", gap: "0.4rem", marginBottom: "0.7rem" }}
|
|
2275
|
+
>
|
|
2276
|
+
<button
|
|
2277
|
+
type="button"
|
|
2278
|
+
role="tab"
|
|
2279
|
+
aria-selected={surfaceMode === "clip"}
|
|
2280
|
+
className={surfaceMode === "clip" ? "vi-btn vi-btn-accent" : "vi-btn vi-btn-ghost"}
|
|
2281
|
+
onClick={() => setInternalSurfaceMode("clip")}
|
|
2282
|
+
>
|
|
2283
|
+
Clip
|
|
2284
|
+
</button>
|
|
2285
|
+
<button
|
|
2286
|
+
type="button"
|
|
2287
|
+
role="tab"
|
|
2288
|
+
aria-selected={surfaceMode === "movie"}
|
|
2289
|
+
className={surfaceMode === "movie" ? "vi-btn vi-btn-accent" : "vi-btn vi-btn-ghost"}
|
|
2290
|
+
onClick={() => setInternalSurfaceMode("movie")}
|
|
2291
|
+
>
|
|
2292
|
+
Cinema
|
|
2293
|
+
</button>
|
|
2294
|
+
</div>
|
|
2295
|
+
)}
|
|
2296
|
+
{surfaceMode === "movie" ? (
|
|
2297
|
+
/* Movie mode portals its own "Movie templates" dropdown into the SAME Settings-tab
|
|
2298
|
+
host the Clip knobGrid uses (the composer owns that portal).
|
|
2299
|
+
NB no "Test all models" here: Cinema is the multi-GOAL composer (its prompt
|
|
2300
|
+
lives per-goal inside StudioMovieComposer, not in this surface's single
|
|
2301
|
+
`prompt`), so a single-prompt sweep doesn't map. The top-level Movie tab
|
|
2302
|
+
already offers a movie-model sweep from one prompt. */
|
|
2303
|
+
<StudioMovieComposer settingsHost={settingsHost} />
|
|
2304
|
+
) : (
|
|
2305
|
+
<>
|
|
2306
|
+
{/* Round 8: the knob grid renders in the left-sidebar Settings tab (portaled into
|
|
2307
|
+
settingsHost); null until the sidebar mounts its Settings panel. */}
|
|
2308
|
+
{settingsHost ? createPortal(knobGrid, settingsHost) : null}
|
|
2309
|
+
{/* Subject-clip "generate new" note — ALWAYS visible (unlike renderSource, which
|
|
2310
|
+
stops rendering entirely once onGenerateNewSubject forces the t2v shape), so
|
|
2311
|
+
the "what's happening" signal survives that shape change. Clears itself the
|
|
2312
|
+
moment the wait resolves (success, failure, or a manual override elsewhere). */}
|
|
2313
|
+
{(pendingSubjectStage || awaitingSubjectJobId) && (
|
|
2314
|
+
<p className="vi-input-callout" role="note">
|
|
2315
|
+
{awaitingSubjectJobId
|
|
2316
|
+
? "Waiting for the new clip to finish rendering — it becomes the staged subject automatically."
|
|
2317
|
+
: "No subject staged. Write a prompt and press Generate below — the clip it produces becomes the staged subject."}
|
|
2318
|
+
</p>
|
|
2319
|
+
)}
|
|
2320
|
+
{/* ── 1) TEMPLATE/TIER CARDS — RETIRED from the centre (Round 9): template
|
|
2321
|
+
selection now lives in the Settings-tab dropdown (mirrors Movie's PRESET
|
|
2322
|
+
dropdown). The card grid is kept below, ARCHIVED + never rendered (`false &&`),
|
|
2323
|
+
per the no-delete house rule. ── */}
|
|
2324
|
+
{false && (
|
|
2325
|
+
<section aria-label="Studio templates" className="vi-studio-templates">
|
|
2326
|
+
<p className="vi-knob-headline">
|
|
2327
|
+
Choose a template{" "}
|
|
2328
|
+
<span className="vi-knob-headline-sub">
|
|
2329
|
+
the tier you pick is the mode — it sets the shape and prefills the knobs below
|
|
2330
|
+
</span>
|
|
2331
|
+
</p>
|
|
2332
|
+
{presetsLoading ? (
|
|
2333
|
+
<p className="vi-knob-hint">Loading templates…</p>
|
|
2334
|
+
) : generalPresets.length === 0 ? (
|
|
2335
|
+
<p className="vi-knob-hint">No real tiers registered.</p>
|
|
2336
|
+
) : (
|
|
2337
|
+
<div className="vi-studio-card-grid">{generalPresets.map((p) => presetCard(p))}</div>
|
|
2338
|
+
)}
|
|
2339
|
+
|
|
2340
|
+
{lockPresets.length > 0 && (
|
|
2341
|
+
<>
|
|
2342
|
+
<p className="vi-knob-subhead">
|
|
2343
|
+
Identity lock & scene continuity{" "}
|
|
2344
|
+
<span className="vi-knob-headline-sub">
|
|
2345
|
+
— lock a person / thing / place to reference images, or continue a prior scene
|
|
2346
|
+
</span>
|
|
2347
|
+
</p>
|
|
2348
|
+
<div className="vi-studio-card-grid">{lockPresets.map((p) => presetCard(p))}</div>
|
|
2349
|
+
</>
|
|
2350
|
+
)}
|
|
2351
|
+
|
|
2352
|
+
{synthPresets.length > 0 && (
|
|
2353
|
+
<>
|
|
2354
|
+
<p className="vi-knob-subhead">
|
|
2355
|
+
Synthetic previews{" "}
|
|
2356
|
+
<span className="vi-knob-headline-sub">
|
|
2357
|
+
— no GPU; the prompt is recorded, not rendered
|
|
2358
|
+
</span>
|
|
2359
|
+
</p>
|
|
2360
|
+
<div className="vi-studio-card-grid">{synthPresets.map((p) => presetCard(p))}</div>
|
|
2361
|
+
</>
|
|
2362
|
+
)}
|
|
2363
|
+
{presetId && (
|
|
2364
|
+
<button
|
|
2365
|
+
type="button"
|
|
2366
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
2367
|
+
onClick={() => setPresetId("")}
|
|
2368
|
+
style={{ marginTop: "0.5rem" }}
|
|
2369
|
+
>
|
|
2370
|
+
Clear template (custom)
|
|
2371
|
+
</button>
|
|
2372
|
+
)}
|
|
2373
|
+
</section>
|
|
2374
|
+
)}
|
|
2375
|
+
|
|
2376
|
+
{/* ── 2) COMPOSER — prompt + negative, in the CENTER. Round 8: the Movie knob
|
|
2377
|
+
grid moved to the left-sidebar Settings tab (portaled above), so the
|
|
2378
|
+
center is cards → composer → shape heroes → Run (movie-shape canonical). ── */}
|
|
2379
|
+
<section aria-label="Prompt" className="vi-studio-composer">
|
|
2380
|
+
{/* k89: the clip's prompt + negative rebuilt on the SHARED PromptCard +
|
|
2381
|
+
PromptFieldTabs (the same shell every Studio prompt list renders). A
|
|
2382
|
+
single-prompt surface: no up/down/split/✕ callbacks (the action cluster
|
|
2383
|
+
hides), and deliberately NO PromptListToolbar — a count/select toolbar
|
|
2384
|
+
over one card would be noise. Same state; the attach toolbar + chips
|
|
2385
|
+
move into the uniform attach-row position below the assist cluster. */}
|
|
2386
|
+
<ol className="vi-gen-parts">
|
|
2387
|
+
<PromptCard
|
|
2388
|
+
badge="clip"
|
|
2389
|
+
index={0}
|
|
2390
|
+
noun="clip"
|
|
2391
|
+
headerHint={
|
|
2392
|
+
<span className="vi-comfy-hint" style={{ opacity: 0.75 }}>
|
|
2393
|
+
{isV2V
|
|
2394
|
+
? "video → video (restyle)"
|
|
2395
|
+
: isMotion
|
|
2396
|
+
? "pose / structure → video"
|
|
2397
|
+
: hasImage
|
|
2398
|
+
? "image → video"
|
|
2399
|
+
: "text → video"}
|
|
2400
|
+
{capProven === false ? " · unproven on this fleet" : ""}
|
|
2401
|
+
</span>
|
|
2402
|
+
}
|
|
2403
|
+
>
|
|
2404
|
+
<PromptFieldTabs
|
|
2405
|
+
idPrefix="vi-studio-clip"
|
|
2406
|
+
promptLabel="prompt"
|
|
2407
|
+
prompt={prompt}
|
|
2408
|
+
onPromptChange={setPrompt}
|
|
2409
|
+
promptPlaceholder={
|
|
2410
|
+
isV2V ? "repaint this scene as … (describe the new look)" : "prompt"
|
|
2411
|
+
}
|
|
2412
|
+
promptExtras={
|
|
2413
|
+
preset?.prompt_note ? (
|
|
2414
|
+
<span className="vi-knob-hint">
|
|
2415
|
+
<em>ⓘ {preset.prompt_note}</em>
|
|
2416
|
+
</span>
|
|
2417
|
+
) : undefined
|
|
2418
|
+
}
|
|
2419
|
+
negativeLabel="negative prompt"
|
|
2420
|
+
negative={negative}
|
|
2421
|
+
onNegativeChange={setNegative}
|
|
2422
|
+
negativePlaceholder="what to avoid"
|
|
2423
|
+
stdNegative={stdNegative}
|
|
2424
|
+
onStdNegativeChange={setStdNegative}
|
|
2425
|
+
/>
|
|
2426
|
+
{/* PROMPT ASSIST — Enhance enriches the clip prompt, Generate writes a fresh
|
|
2427
|
+
one (seeded by any current text). Same shared cluster as the Generate
|
|
2428
|
+
station; errors surface inline and never clobber the existing prompt. */}
|
|
2429
|
+
<PromptAssistButtons
|
|
2430
|
+
busy={assist.assistBusy}
|
|
2431
|
+
error={assist.assistError}
|
|
2432
|
+
onDismissError={assist.dismissError}
|
|
2433
|
+
onEnhance={() => void assist.runAssist("detail", prompt, setPrompt)}
|
|
2434
|
+
onGenerate={() => void assist.runAssist("generate", prompt, setPrompt)}
|
|
2435
|
+
canEnhance={prompt.trim() !== ""}
|
|
2436
|
+
models={assist.assistModels}
|
|
2437
|
+
model={assist.assistModel}
|
|
2438
|
+
onModelChange={assist.setAssistModel}
|
|
2439
|
+
/>
|
|
2440
|
+
{/* ATTACH ROW — the same chips + buttons GoalComposer used to render
|
|
2441
|
+
inline, now in the uniform per-card attach position. Same state and
|
|
2442
|
+
callbacks (promptAttachments / promptAttached above); the chip markup
|
|
2443
|
+
mirrors GoalComposer's .vi-goal-composer-chip idiom verbatim. */}
|
|
2444
|
+
{promptAttached.length > 0 && (
|
|
2445
|
+
<div className="vi-goal-composer-chips">
|
|
2446
|
+
{promptAttached.map((a, i) => (
|
|
2447
|
+
<span key={`${a.ref.uri}-${i}`} className="vi-goal-composer-chip">
|
|
2448
|
+
{a.ref.kind === "image" ? (
|
|
2449
|
+
<img
|
|
2450
|
+
src={mediaBytesUrl(a.ref.uri)}
|
|
2451
|
+
alt={a.label ?? "attachment"}
|
|
2452
|
+
className="vi-goal-composer-chip-thumb"
|
|
2453
|
+
/>
|
|
2454
|
+
) : (
|
|
2455
|
+
<span className="vi-goal-composer-chip-kind">{a.ref.kind}</span>
|
|
2456
|
+
)}
|
|
2457
|
+
<span className="vi-goal-composer-chip-label">
|
|
2458
|
+
{a.label ?? a.ref.asset_id.slice(0, 8)}
|
|
2459
|
+
</span>
|
|
2460
|
+
{a.onRemove && (
|
|
2461
|
+
<button
|
|
2462
|
+
type="button"
|
|
2463
|
+
className="vi-goal-composer-chip-x"
|
|
2464
|
+
onClick={a.onRemove}
|
|
2465
|
+
aria-label="Remove attachment"
|
|
2466
|
+
title="Remove"
|
|
2467
|
+
>
|
|
2468
|
+
✕
|
|
2469
|
+
</button>
|
|
2470
|
+
)}
|
|
2471
|
+
</span>
|
|
2472
|
+
))}
|
|
2473
|
+
</div>
|
|
2474
|
+
)}
|
|
2475
|
+
{promptAttachments.length > 0 && (
|
|
2476
|
+
<div className="vi-gen-part-attach">
|
|
2477
|
+
{promptAttachments.map((a, i) => (
|
|
2478
|
+
<button
|
|
2479
|
+
key={`${a.label}-${i}`}
|
|
2480
|
+
type="button"
|
|
2481
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
2482
|
+
onClick={a.onClick}
|
|
2483
|
+
disabled={a.disabled}
|
|
2484
|
+
title={a.title}
|
|
2485
|
+
>
|
|
2486
|
+
{a.label}
|
|
2487
|
+
</button>
|
|
2488
|
+
))}
|
|
2489
|
+
</div>
|
|
2490
|
+
)}
|
|
2491
|
+
</PromptCard>
|
|
2492
|
+
</ol>
|
|
2493
|
+
</section>
|
|
2494
|
+
|
|
2495
|
+
{/* Required-input callout — surfaces the missing prerequisite PROMINENTLY next to
|
|
2496
|
+
the composer now that the tier cards (which used to carry the "needs input" cue)
|
|
2497
|
+
are retired. The required input itself is the hero shape directly below. */}
|
|
2498
|
+
{missingRequired && (
|
|
2499
|
+
<p className="vi-studio-required-callout" role="note">
|
|
2500
|
+
⚠ {missingReason}
|
|
2501
|
+
</p>
|
|
2502
|
+
)}
|
|
2503
|
+
|
|
2504
|
+
{/* ── 3) SHAPE EDITOR (heroes) — morphs per preformed shape; stays dominant when
|
|
2505
|
+
required (accent gating) even though it now sits below the knob grid. ── */}
|
|
2506
|
+
{hero === "start-image" && (
|
|
2507
|
+
<section
|
|
2508
|
+
aria-label="Required input"
|
|
2509
|
+
className="vi-studio-shape"
|
|
2510
|
+
style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
|
|
2511
|
+
>
|
|
2512
|
+
{renderStartImage("hero")}
|
|
2513
|
+
{renderSource("inline-alt")}
|
|
2514
|
+
</section>
|
|
2515
|
+
)}
|
|
2516
|
+
{hero === "source" && (
|
|
2517
|
+
<section
|
|
2518
|
+
aria-label="Required input"
|
|
2519
|
+
className="vi-studio-shape"
|
|
2520
|
+
style={{ display: "flex", flexDirection: "column", gap: "0.6rem" }}
|
|
2521
|
+
>
|
|
2522
|
+
{renderSource("hero")}
|
|
2523
|
+
{/* scene-continuity: OPTIONALLY hold a subject's identity across the restyle. */}
|
|
2524
|
+
{renderReferenceSlots("optional")}
|
|
2525
|
+
</section>
|
|
2526
|
+
)}
|
|
2527
|
+
{hero === "reference" && (
|
|
2528
|
+
<section
|
|
2529
|
+
aria-label="Required input"
|
|
2530
|
+
className="vi-studio-shape"
|
|
2531
|
+
style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
|
|
2532
|
+
>
|
|
2533
|
+
{renderReferenceSlots("required")}
|
|
2534
|
+
{renderControl()}
|
|
2535
|
+
</section>
|
|
2536
|
+
)}
|
|
2537
|
+
{/* motion (2026-07-29): the control still IS the render, so it is the hero and the
|
|
2538
|
+
only input this shape offers — no source clip (the route refuses the pair), no
|
|
2539
|
+
references (a non-VACE-reference branch would silently ignore them). */}
|
|
2540
|
+
{hero === "control" && (
|
|
2541
|
+
<section
|
|
2542
|
+
aria-label="Required input"
|
|
2543
|
+
className="vi-studio-shape"
|
|
2544
|
+
style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}
|
|
2545
|
+
>
|
|
2546
|
+
{renderControl("required")}
|
|
2547
|
+
</section>
|
|
2548
|
+
)}
|
|
2549
|
+
|
|
2550
|
+
{/* LOWER inputs — only when NOT a hero shape (custom / synthetic / t2v). */}
|
|
2551
|
+
{!hero && isI2V && <div className="vi-studio-shape">{renderStartImage("inline")}</div>}
|
|
2552
|
+
{!hero && !isT2V && !isIdLock && <div className="vi-studio-shape">{renderSource("inline")}</div>}
|
|
2553
|
+
{!hero && isT2V && (
|
|
2554
|
+
<p className="vi-knob-hint" role="note" style={{ marginTop: "0.9rem" }}>
|
|
2555
|
+
Text-to-video: no image inputs needed — the clip is a function of prompt + seed +
|
|
2556
|
+
geometry.
|
|
2557
|
+
</p>
|
|
2558
|
+
)}
|
|
2559
|
+
|
|
2560
|
+
{/* ── SYNTHETIC-TIER HONESTY BANNER — loud, right above Generate, so the
|
|
2561
|
+
operator sees it at the moment it matters instead of a footnote three
|
|
2562
|
+
fields up. Only fires when the CURRENT form state would actually bind
|
|
2563
|
+
synthetic (see wouldBindSynthetic above); the one-click fill jumps
|
|
2564
|
+
straight past STUDIO_REAL_FLOOR_GB with headroom (STUDIO_SAFE_FILL_GB). ── */}
|
|
2565
|
+
{wouldBindSynthetic && (
|
|
2566
|
+
<div
|
|
2567
|
+
className="vi-studio-required-callout"
|
|
2568
|
+
role="alert"
|
|
2569
|
+
style={{
|
|
2570
|
+
display: "flex",
|
|
2571
|
+
alignItems: "center",
|
|
2572
|
+
gap: "0.6rem",
|
|
2573
|
+
flexWrap: "wrap",
|
|
2574
|
+
marginBottom: "0.6rem",
|
|
2575
|
+
}}
|
|
2576
|
+
>
|
|
2577
|
+
<span>
|
|
2578
|
+
⚠ This will render the SYNTHETIC TEST tier (colored noise, no real model). Set VRAM
|
|
2579
|
+
budget ≥ {realFloorGb} for a real render on the GPU worker
|
|
2580
|
+
{capFloorGb != null ? " — the measured envelope of the cheapest real model for this capability" : ""}.
|
|
2581
|
+
</span>
|
|
2582
|
+
<button
|
|
2583
|
+
type="button"
|
|
2584
|
+
className="vi-btn vi-btn-sm vi-btn-accent"
|
|
2585
|
+
disabled={genBusy}
|
|
2586
|
+
onClick={() => {
|
|
2587
|
+
budgetTouched.current = true;
|
|
2588
|
+
setVramBudget(String(safeFillGb));
|
|
2589
|
+
}}
|
|
2590
|
+
>
|
|
2591
|
+
Set budget to {safeFillGb}
|
|
2592
|
+
</button>
|
|
2593
|
+
</div>
|
|
2594
|
+
)}
|
|
2595
|
+
|
|
2596
|
+
{/* ── 4) RUN ROW — the Movie-mode .vi-station-footer idiom (flags/errors on the
|
|
2597
|
+
left, the primary Run affordance + status on the right). ── */}
|
|
2598
|
+
<div className="vi-station-footer vi-studio-run">
|
|
2599
|
+
<div className="vi-station-footer-main">
|
|
2600
|
+
{missingRequired && <span className="vi-knob-flag">{missingReason}</span>}
|
|
2601
|
+
{v2vBadGeom && (
|
|
2602
|
+
<span className="vi-knob-flag">
|
|
2603
|
+
Restyle geometry must be 832×480 landscape (the VACE envelope).
|
|
2604
|
+
</span>
|
|
2605
|
+
)}
|
|
2606
|
+
{genMsg && (
|
|
2607
|
+
<p className="vi-knob-hint" role="status" style={{ margin: 0 }}>
|
|
2608
|
+
{genMsg}
|
|
2609
|
+
</p>
|
|
2610
|
+
)}
|
|
2611
|
+
</div>
|
|
2612
|
+
<div className="vi-station-footer-run">
|
|
2613
|
+
<button
|
|
2614
|
+
type="button"
|
|
2615
|
+
className="vi-btn vi-btn-accent"
|
|
2616
|
+
disabled={genBusy || blockSubmit}
|
|
2617
|
+
onClick={() => void onGenerate()}
|
|
2618
|
+
title={blockSubmit ? (v2vBadGeom ? "Fix the geometry" : missingReason) : undefined}
|
|
2619
|
+
>
|
|
2620
|
+
{genBusy
|
|
2621
|
+
? "Enqueuing…"
|
|
2622
|
+
: isV2V
|
|
2623
|
+
? "Restyle clip"
|
|
2624
|
+
: isIdLock
|
|
2625
|
+
? "Generate (identity lock)"
|
|
2626
|
+
: isMotion
|
|
2627
|
+
? "Generate (pose / structure)"
|
|
2628
|
+
: isI2V
|
|
2629
|
+
? "Generate (i2v)"
|
|
2630
|
+
: "Generate (t2v)"}
|
|
2631
|
+
</button>
|
|
2632
|
+
{/* (The cross-model "🧪 Test all models" ghost that sat here moved into
|
|
2633
|
+
the bottom Tester collapsible below — k89.) */}
|
|
2634
|
+
</div>
|
|
2635
|
+
</div>
|
|
2636
|
+
|
|
2637
|
+
{/* CLIP TESTER (k89) — no row-based tester on this surface, so the bottom
|
|
2638
|
+
"[▸ Tester]" collapsible holds just the cross-model sweep folded in from
|
|
2639
|
+
the run row (same handler, untouched). Its enqueue/result feedback rides
|
|
2640
|
+
the shared genMsg line in the run row above; per-model results stream to
|
|
2641
|
+
the generation log below this surface. */}
|
|
2642
|
+
<div className="vi-gen-tester">
|
|
2643
|
+
<div className="vi-gen-tester-head">
|
|
2644
|
+
<button
|
|
2645
|
+
type="button"
|
|
2646
|
+
className="vi-btn vi-btn-sm vi-btn-ghost"
|
|
2647
|
+
aria-expanded={clipTesterOpen}
|
|
2648
|
+
onClick={() => setClipTesterOpen((v) => !v)}
|
|
2649
|
+
title="Sweep the prompt across every clip model"
|
|
2650
|
+
>
|
|
2651
|
+
{clipTesterOpen ? "▾" : "▸"} Tester
|
|
2652
|
+
</button>
|
|
2653
|
+
<span className="vi-comfy-hint">
|
|
2654
|
+
Sweep one prompt across every clip model — results stream to the
|
|
2655
|
+
generation log below.
|
|
2656
|
+
</span>
|
|
2657
|
+
</div>
|
|
2658
|
+
{clipTesterOpen && (
|
|
2659
|
+
<div className="vi-gen-tester-actions">
|
|
2660
|
+
<button
|
|
2661
|
+
type="button"
|
|
2662
|
+
className="vi-btn vi-btn-sm vi-btn-tester"
|
|
2663
|
+
disabled={testerBusy || !prompt.trim()}
|
|
2664
|
+
onClick={() =>
|
|
2665
|
+
void onTestAllModels({
|
|
2666
|
+
category: surfaceMode,
|
|
2667
|
+
models: clipTesterModels,
|
|
2668
|
+
width,
|
|
2669
|
+
height,
|
|
2670
|
+
fps,
|
|
2671
|
+
seed,
|
|
2672
|
+
})
|
|
2673
|
+
}
|
|
2674
|
+
title="Run this prompt across every model that FITS THE CURRENT FILTER (this surface's capability + geometry) — one row per model in the log below (a battery). Falls back to the whole roster when none match."
|
|
2675
|
+
>
|
|
2676
|
+
{testerBusy ? "Starting…" : "🧪 Test all models"}
|
|
2677
|
+
</button>
|
|
2678
|
+
</div>
|
|
2679
|
+
)}
|
|
2680
|
+
</div>
|
|
2681
|
+
</>
|
|
2682
|
+
)}
|
|
2683
|
+
{/* STUDIO-ASSIST LIVE LOG: renders in the sidebar's ACTIVE tab now (operator
|
|
2684
|
+
correction 2026-08-05 — "active" meant Active Processes, not the active
|
|
2685
|
+
generate surface). StudioPlane portals it via reg.activeExtraHost. */}
|
|
2686
|
+
</div>
|
|
2687
|
+
);
|
|
2688
|
+
}
|