@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,189 @@
|
|
|
1
|
+
// SESSION-SCOPED FORM MEMORY — "my inputs are still there when I come back".
|
|
2
|
+
//
|
|
3
|
+
// ── THE BUG THIS FIXES (operator ask 2026-08-06) ────────────────────────────
|
|
4
|
+
// Switching sub-tabs inside the STUDIO tab (Scene / Movie / Clip / Cinema) and
|
|
5
|
+
// switching workbench tabs (Studio ↔ Identities ↔ Frames …) both UNMOUNT the
|
|
6
|
+
// surface you were typing into: WorkbenchStation renders exactly one station
|
|
7
|
+
// body, and GenerateStation swaps the SectionTabs layout for StudioGenerateMode
|
|
8
|
+
// (and StudioGenerateSurface swaps its clip surface for StudioMovieComposer).
|
|
9
|
+
// Plain useState dies with the unmount, so a half-written prompt, a chosen
|
|
10
|
+
// model, a staged reference set — all gone for the price of a glance at another
|
|
11
|
+
// tab.
|
|
12
|
+
//
|
|
13
|
+
// ── WHY A STORE AND NOT KEEP-MOUNTED-BUT-HIDDEN ─────────────────────────────
|
|
14
|
+
// Rendering the inactive sub-tabs behind `hidden` would fix the SUB-tab half and
|
|
15
|
+
// nothing else — a main-tab switch unmounts the whole station regardless, and
|
|
16
|
+
// keeping every station mounted is not on the table. It would also double-mount
|
|
17
|
+
// the pieces that register into the shared sidebar (StudioPlane's
|
|
18
|
+
// registerSettings + its knob PORTAL both land in the ONE settingsHost node) and
|
|
19
|
+
// double the studio clip poll. So state is LIFTED out of the component lifetime
|
|
20
|
+
// instead — which is exactly the idiom this arm already uses for everything else
|
|
21
|
+
// that has to outlive a mount: mediaLibrary.ts and jobTracker.ts are both
|
|
22
|
+
// module-level stores persisted to sessionStorage under the session id.
|
|
23
|
+
//
|
|
24
|
+
// ── SHAPE ───────────────────────────────────────────────────────────────────
|
|
25
|
+
// A per-tab key/value store, session-id-scoped like mediaLibrary (so two tabs
|
|
26
|
+
// keep independent inputs and a new session starts clean), with a useState-shaped
|
|
27
|
+
// hook so a call site converts by swapping one word:
|
|
28
|
+
//
|
|
29
|
+
// const [prompt, setPrompt] = useState("");
|
|
30
|
+
// const [prompt, setPrompt] = useSessionState("studio.clip.prompt", "");
|
|
31
|
+
//
|
|
32
|
+
// In-memory is authoritative (synchronous, no JSON round-trip per keystroke);
|
|
33
|
+
// sessionStorage is a debounced mirror so a reload also keeps the inputs. If
|
|
34
|
+
// storage is unavailable (private mode, quota) the memory half still works — the
|
|
35
|
+
// feature degrades to "survives unmounts but not reloads", never to an error.
|
|
36
|
+
//
|
|
37
|
+
// ── WHAT BELONGS HERE, AND WHAT DOES NOT ────────────────────────────────────
|
|
38
|
+
// USER-AUTHORED INPUT only: prompts, knobs, picked models/presets, attached
|
|
39
|
+
// refs, which sub-tab you were on. NOT transient UI (open pickers, previews),
|
|
40
|
+
// NOT in-flight job/upload state, NOT anything unserializable (a Set/Map never
|
|
41
|
+
// survives JSON — leave those on useState). Restoring a stale "uploading…" or a
|
|
42
|
+
// half-open picker would be a worse bug than the one being fixed.
|
|
43
|
+
//
|
|
44
|
+
// Keys are global: two components sharing a key share the VALUE but not the
|
|
45
|
+
// React subscription (each holds its own useState), so only ever use one key per
|
|
46
|
+
// logical field, and never for two surfaces that are mounted at the same time.
|
|
47
|
+
import { useCallback, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
|
48
|
+
import { getSessionId } from "../session";
|
|
49
|
+
import { isCanned } from "../demo/mode";
|
|
50
|
+
|
|
51
|
+
const KEY_PREFIX = "vi.formMemory.v1";
|
|
52
|
+
const FLUSH_MS = 300;
|
|
53
|
+
|
|
54
|
+
// Session-scoped key so two tabs (two sessions) keep independent inputs, with the
|
|
55
|
+
// same `:demo`-suffix hermeticism mediaLibrary/jobTracker use so the canned
|
|
56
|
+
// brochure never reads or writes the live session's values.
|
|
57
|
+
function storageKey(): string {
|
|
58
|
+
const suffix = isCanned() ? ":demo" : "";
|
|
59
|
+
try {
|
|
60
|
+
return `${KEY_PREFIX}:${getSessionId()}${suffix}`;
|
|
61
|
+
} catch {
|
|
62
|
+
return `${KEY_PREFIX}${suffix}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function hydrate(): Map<string, unknown> {
|
|
67
|
+
try {
|
|
68
|
+
const raw = sessionStorage.getItem(storageKey());
|
|
69
|
+
if (!raw) return new Map();
|
|
70
|
+
const parsed = JSON.parse(raw);
|
|
71
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return new Map();
|
|
72
|
+
return new Map(Object.entries(parsed as Record<string, unknown>));
|
|
73
|
+
} catch {
|
|
74
|
+
return new Map(); // storage unavailable or corrupt — start empty, quietly
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Hydrated ONCE at module load: every read after that is a Map lookup, so a
|
|
79
|
+
// keystroke never pays a JSON.parse.
|
|
80
|
+
const memory: Map<string, unknown> = hydrate();
|
|
81
|
+
|
|
82
|
+
let flushTimer: number | null = null;
|
|
83
|
+
|
|
84
|
+
function flush(): void {
|
|
85
|
+
flushTimer = null;
|
|
86
|
+
try {
|
|
87
|
+
sessionStorage.setItem(
|
|
88
|
+
storageKey(),
|
|
89
|
+
JSON.stringify(Object.fromEntries(memory)),
|
|
90
|
+
);
|
|
91
|
+
} catch {
|
|
92
|
+
/* quota/private mode — the in-memory half still carries this session */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function scheduleFlush(): void {
|
|
97
|
+
if (typeof window === "undefined") return;
|
|
98
|
+
if (flushTimer != null) return;
|
|
99
|
+
flushTimer = window.setTimeout(flush, FLUSH_MS);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function readValue<T>(key: string, fallback: T): T {
|
|
103
|
+
return memory.has(key) ? (memory.get(key) as T) : fallback;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function writeValue(key: string, value: unknown): void {
|
|
107
|
+
memory.set(key, value);
|
|
108
|
+
scheduleFlush();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* useState, but the value outlives the component's mount for the whole browser
|
|
113
|
+
* session (and a reload). Drop-in: same tuple, same updater-function support.
|
|
114
|
+
*
|
|
115
|
+
* `initial` is used only the FIRST time a key is seen this session; afterwards
|
|
116
|
+
* the stored value wins, which is the entire point (a remount must not re-run
|
|
117
|
+
* the default over what the operator typed).
|
|
118
|
+
*/
|
|
119
|
+
export function useSessionState<T>(
|
|
120
|
+
key: string,
|
|
121
|
+
initial: T | (() => T),
|
|
122
|
+
): [T, Dispatch<SetStateAction<T>>] {
|
|
123
|
+
const [value, setValue] = useState<T>(() => {
|
|
124
|
+
if (memory.has(key)) return memory.get(key) as T;
|
|
125
|
+
const seed = typeof initial === "function" ? (initial as () => T)() : initial;
|
|
126
|
+
writeValue(key, seed);
|
|
127
|
+
return seed;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const set = useCallback<Dispatch<SetStateAction<T>>>(
|
|
131
|
+
(action) => {
|
|
132
|
+
setValue((prev) => {
|
|
133
|
+
const next =
|
|
134
|
+
typeof action === "function" ? (action as (p: T) => T)(prev) : action;
|
|
135
|
+
// Writing from inside the updater keeps the store in step with the
|
|
136
|
+
// functional-update form (setX(cur => …) is used all over these
|
|
137
|
+
// surfaces). React may invoke an updater twice under StrictMode; the
|
|
138
|
+
// write is idempotent for the same `prev`, so that is harmless.
|
|
139
|
+
writeValue(key, next);
|
|
140
|
+
return next;
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
[key],
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return [value, set];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The next value of a session-persisted counter — for the module-level `let seq
|
|
151
|
+
* = 0` row-key generators (`goal_3`, `ref_2`) that back rows now restored from
|
|
152
|
+
* storage.
|
|
153
|
+
*
|
|
154
|
+
* Those counters reset to 0 on a page reload while the ROWS they keyed come back
|
|
155
|
+
* from sessionStorage, so a freshly minted key could collide with a restored one
|
|
156
|
+
* (React would then reconcile two different rows as the same element, and in the
|
|
157
|
+
* movie composer the key doubles as the wire `segment_id`). Persisting the
|
|
158
|
+
* counter is the smallest fix that keeps the existing key SHAPE intact.
|
|
159
|
+
*/
|
|
160
|
+
export function nextSessionSeq(key: string): number {
|
|
161
|
+
const next = (readValue<number>(key, 0) ?? 0) + 1;
|
|
162
|
+
writeValue(key, next);
|
|
163
|
+
return next;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A ref whose `.current` lives in the same session store — for the flags that
|
|
168
|
+
* deliberately are NOT state because they must never cause a render (the
|
|
169
|
+
* "operator has touched this field" marks that gate the studio's geometry/budget
|
|
170
|
+
* autofill effects).
|
|
171
|
+
*
|
|
172
|
+
* These have to persist alongside the values they guard: restore a hand-typed
|
|
173
|
+
* width but not the "the operator typed it" mark, and the autofill effect fires
|
|
174
|
+
* on the next mount and clobbers exactly the value we just restored.
|
|
175
|
+
*/
|
|
176
|
+
export function useSessionRef<T>(key: string, initial: T): { current: T } {
|
|
177
|
+
const holder = useRef<{ current: T } | null>(null);
|
|
178
|
+
if (holder.current === null) {
|
|
179
|
+
if (!memory.has(key)) writeValue(key, initial);
|
|
180
|
+
const obj = {} as { current: T };
|
|
181
|
+
Object.defineProperty(obj, "current", {
|
|
182
|
+
get: () => readValue<T>(key, initial),
|
|
183
|
+
set: (v: T) => writeValue(key, v),
|
|
184
|
+
enumerable: true,
|
|
185
|
+
});
|
|
186
|
+
holder.current = obj;
|
|
187
|
+
}
|
|
188
|
+
return holder.current;
|
|
189
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// STUDIO SPREAD — the pure half (STUDIO-SPREAD-SPEC §2).
|
|
2
|
+
//
|
|
3
|
+
// Everything here is a FUNCTION OF ITS INPUTS: wire shapes, request builders,
|
|
4
|
+
// tolerant response readers and the intent→action mapping. No React, no fetch, no
|
|
5
|
+
// component state. The transport lives in usePromptAssist (one in-flight assist,
|
|
6
|
+
// one error model); the composer keeps only its editor state. Splitting it this way
|
|
7
|
+
// keeps StudioMovieComposer from growing a second brain, and makes the two things
|
|
8
|
+
// most likely to be wrong — the wire shape and the intent routing table — readable
|
|
9
|
+
// in one screen.
|
|
10
|
+
//
|
|
11
|
+
// THE ONE RULE THAT SHAPES ALL OF IT (spec §1a/§1d): a classification is a
|
|
12
|
+
// LABEL, never an action, and a spread reply may only touch rows the user
|
|
13
|
+
// SELECTED. Every reader below is written so a malformed/partial/over-eager reply
|
|
14
|
+
// degrades to "change nothing and say so" rather than to "overwrite the movie".
|
|
15
|
+
|
|
16
|
+
/** How a segment is spliced onto the one before it — mirrors studio_movie_schema. */
|
|
17
|
+
export type SpreadJointMode = "still" | "vace_extend" | "cut";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One typed segment reference (spec §1c). Field names mirror
|
|
21
|
+
* `studio_movie_schema.StudioMovieGoal` EXACTLY so a row goes over the wire with no
|
|
22
|
+
* translation layer to get wrong. Every field bar `segment_id` is optional; the
|
|
23
|
+
* backend validates and renders them into the model preface as plain sentences.
|
|
24
|
+
*/
|
|
25
|
+
export interface SegmentRefWire {
|
|
26
|
+
segment_id: string;
|
|
27
|
+
prompt?: string;
|
|
28
|
+
negative?: string;
|
|
29
|
+
/** The user's INSTRUCTION for this row, when the router said "direction". */
|
|
30
|
+
direction?: string;
|
|
31
|
+
joint_mode?: SpreadJointMode;
|
|
32
|
+
branch_frame?: number;
|
|
33
|
+
seed?: number;
|
|
34
|
+
index?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The locked identity block (§1e). `name` is required by the backend validator. */
|
|
38
|
+
export interface IdentityContextWire {
|
|
39
|
+
identity_id?: string;
|
|
40
|
+
name: string;
|
|
41
|
+
reference_asset_ids?: string[];
|
|
42
|
+
locked_description?: string;
|
|
43
|
+
do_not_invent?: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The typed `context` object shared by detail/generate/negative/spread. */
|
|
47
|
+
export interface AssistContextWire {
|
|
48
|
+
kind?: "image" | "scene" | "movie";
|
|
49
|
+
/** FREE-FORM user text only — structured row state rides the typed fields (§1c). */
|
|
50
|
+
hint?: string;
|
|
51
|
+
segment?: SegmentRefWire;
|
|
52
|
+
previous_segment?: SegmentRefWire;
|
|
53
|
+
next_segment?: SegmentRefWire;
|
|
54
|
+
identity_profile?: IdentityContextWire;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The `mode:"spread"` request body (§1a), minus `model` (the hook adds that). */
|
|
58
|
+
export interface SpreadRequestWire {
|
|
59
|
+
mode: "spread";
|
|
60
|
+
movie_query?: string;
|
|
61
|
+
style_bible?: Record<string, string>;
|
|
62
|
+
fixed_segments: SegmentRefWire[];
|
|
63
|
+
target_segments: SegmentRefWire[];
|
|
64
|
+
global_negative?: string[];
|
|
65
|
+
steering_seed?: number;
|
|
66
|
+
context?: AssistContextWire;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One replacement row the generator wrote. Only TARGET ids ever appear here. */
|
|
70
|
+
export interface SpreadSegmentResult {
|
|
71
|
+
segment_id: string;
|
|
72
|
+
operation: string;
|
|
73
|
+
prompt: string;
|
|
74
|
+
negative: string;
|
|
75
|
+
continuity_note: string;
|
|
76
|
+
directions_used: unknown[];
|
|
77
|
+
warnings: string[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The parsed 200 body of a spread call. */
|
|
81
|
+
export interface SpreadResult {
|
|
82
|
+
segments: SpreadSegmentResult[];
|
|
83
|
+
missing_segments: string[];
|
|
84
|
+
warnings: string[];
|
|
85
|
+
invented_identity_attributes: string[];
|
|
86
|
+
/** The shared steering set + its seed — echoed so a liked spread can be re-pinned. */
|
|
87
|
+
steering: Record<string, unknown>;
|
|
88
|
+
steering_seed: number | null;
|
|
89
|
+
model: string | null;
|
|
90
|
+
model_requested: string | null;
|
|
91
|
+
model_resolved: string | null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── intent routing (§1d) ────────────────────────────────────────────────────
|
|
95
|
+
/** What the 3B router can say. `ambiguous` is also the degraded/failure value. */
|
|
96
|
+
export type IntentName = "empty" | "direction" | "scene_prompt" | "ambiguous";
|
|
97
|
+
|
|
98
|
+
/** The parsed /video/prompt/intent 200 body. This route ALWAYS returns 200. */
|
|
99
|
+
export interface IntentResult {
|
|
100
|
+
intent: IntentName;
|
|
101
|
+
operation: string | null;
|
|
102
|
+
confidence: number;
|
|
103
|
+
cached: boolean;
|
|
104
|
+
degraded: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The per-field tri-state control: Auto · Direction · Scene prompt. */
|
|
108
|
+
export type PromptFieldMode = "auto" | "direction" | "scene_prompt";
|
|
109
|
+
|
|
110
|
+
/** The resolved action for a prompt field — null means "uncertain, show both". */
|
|
111
|
+
export type PromptOperation = "generate" | "enhance_scene" | "generate_from_direction";
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Resolve which action a row's primary button should invoke.
|
|
115
|
+
*
|
|
116
|
+
* Routing order is the spec's (§1d), in order: blank field → generate; an EXPLICIT
|
|
117
|
+
* user mode → obey it (the router is never consulted); else the router's answer;
|
|
118
|
+
* else — including `ambiguous`, a degraded router, or "not classified yet" — null,
|
|
119
|
+
* which the UI renders as "Uncertain → choose an action" with BOTH buttons live.
|
|
120
|
+
*/
|
|
121
|
+
export function resolveOperation(
|
|
122
|
+
mode: PromptFieldMode,
|
|
123
|
+
intent: IntentResult | null,
|
|
124
|
+
promptIsBlank: boolean,
|
|
125
|
+
): PromptOperation | null {
|
|
126
|
+
if (promptIsBlank) return "generate";
|
|
127
|
+
if (mode === "direction") return "generate_from_direction";
|
|
128
|
+
if (mode === "scene_prompt") return "enhance_scene";
|
|
129
|
+
if (!intent) return null; // not classified yet — no guess
|
|
130
|
+
if (intent.intent === "empty") return "generate";
|
|
131
|
+
if (intent.intent === "direction") return "generate_from_direction";
|
|
132
|
+
if (intent.intent === "scene_prompt") return "enhance_scene";
|
|
133
|
+
return null; // ambiguous / degraded
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Which /video/prompt/assist mode an operation invokes. */
|
|
137
|
+
export function assistModeFor(op: PromptOperation): "detail" | "generate" {
|
|
138
|
+
return op === "enhance_scene" ? "detail" : "generate";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The button label for an operation (the detected verb the spec names). */
|
|
142
|
+
export function operationLabel(op: PromptOperation): string {
|
|
143
|
+
if (op === "enhance_scene") return "Enhance prompt";
|
|
144
|
+
if (op === "generate_from_direction") return "Generate from direction";
|
|
145
|
+
return "Generate prompt";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The honest one-line readout under a field in AUTO mode. Returns "" when there is
|
|
150
|
+
* nothing to say yet (blank field, or no classification has been asked for).
|
|
151
|
+
*/
|
|
152
|
+
export function detectionText(
|
|
153
|
+
intent: IntentResult | null,
|
|
154
|
+
op: PromptOperation | null,
|
|
155
|
+
): string {
|
|
156
|
+
if (!intent) return "";
|
|
157
|
+
if (intent.intent === "empty") return "Empty → Generate prompt";
|
|
158
|
+
if (op == null) {
|
|
159
|
+
return intent.degraded
|
|
160
|
+
? "Router unavailable → choose an action"
|
|
161
|
+
: "Uncertain → choose an action";
|
|
162
|
+
}
|
|
163
|
+
const what = intent.intent === "direction" ? "Direction" : "Scene prompt";
|
|
164
|
+
return `Detected: ${what} → ${operationLabel(op)}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── request builders ────────────────────────────────────────────────────────
|
|
168
|
+
/** Drop blank strings / undefined so the wire carries only what the user actually set. */
|
|
169
|
+
function put<T extends object>(obj: T, key: keyof T & string, value: unknown): void {
|
|
170
|
+
if (value === undefined || value === null) return;
|
|
171
|
+
if (typeof value === "string" && value.trim() === "") return;
|
|
172
|
+
(obj as Record<string, unknown>)[key] = typeof value === "string" ? value.trim() : value;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A composer goal row, in the shape this module needs (host-agnostic). */
|
|
176
|
+
export interface SpreadGoalInput {
|
|
177
|
+
/** Stable per-row id — the composer's React key doubles as the segment_id. */
|
|
178
|
+
key: string;
|
|
179
|
+
prompt: string;
|
|
180
|
+
negative: string;
|
|
181
|
+
seed: string;
|
|
182
|
+
branchFrame: string;
|
|
183
|
+
joint: SpreadJointMode;
|
|
184
|
+
/** Timeline position (0 = the root segment). */
|
|
185
|
+
index: number;
|
|
186
|
+
/** Set when the row's resolved operation is "generate from direction". */
|
|
187
|
+
isDirection?: boolean;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Parse a blank-means-unset numeric editor string into an int, or undefined. */
|
|
191
|
+
function intOrUndef(raw: string): number | undefined {
|
|
192
|
+
const s = raw.trim();
|
|
193
|
+
if (s === "") return undefined;
|
|
194
|
+
const n = Number(s);
|
|
195
|
+
if (!Number.isInteger(n) || n < 0) return undefined;
|
|
196
|
+
return n;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* One goal row → one typed segment reference.
|
|
201
|
+
*
|
|
202
|
+
* `branch_frame` is dropped for a `cut` (a cut carries no frame, and the backend
|
|
203
|
+
* would render a sentence about a frame that does not exist). A row classified as a
|
|
204
|
+
* DIRECTION sends its text under BOTH `prompt` (what is in the box now) and
|
|
205
|
+
* `direction` (the instruction to act on) — the preface renders them distinctly.
|
|
206
|
+
*/
|
|
207
|
+
export function segmentRef(goal: SpreadGoalInput): SegmentRefWire {
|
|
208
|
+
const ref: SegmentRefWire = { segment_id: goal.key };
|
|
209
|
+
put(ref, "prompt", goal.prompt);
|
|
210
|
+
put(ref, "negative", goal.negative);
|
|
211
|
+
if (goal.isDirection) put(ref, "direction", goal.prompt);
|
|
212
|
+
if (goal.index > 0) {
|
|
213
|
+
ref.joint_mode = goal.joint;
|
|
214
|
+
if (goal.joint !== "cut") {
|
|
215
|
+
const bf = intOrUndef(goal.branchFrame);
|
|
216
|
+
if (bf !== undefined) ref.branch_frame = bf;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const seed = intOrUndef(goal.seed);
|
|
220
|
+
if (seed !== undefined) ref.seed = seed;
|
|
221
|
+
ref.index = goal.index;
|
|
222
|
+
return ref;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export interface BuildSpreadOptions {
|
|
226
|
+
goals: SpreadGoalInput[];
|
|
227
|
+
/** Row keys the user SELECTED — these and only these may be rewritten. */
|
|
228
|
+
selectedKeys: ReadonlySet<string>;
|
|
229
|
+
/** The overall request ("a chase through a night market"). Optional. */
|
|
230
|
+
movieQuery: string;
|
|
231
|
+
/** The movie-wide negative, split into exclusion terms. */
|
|
232
|
+
globalNegative: string;
|
|
233
|
+
/** Pin a previous spread's world by reusing its steering seed. */
|
|
234
|
+
steeringSeed?: number | null;
|
|
235
|
+
context?: AssistContextWire;
|
|
236
|
+
styleBible?: Record<string, string>;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Build the ONE spread call (§1a): selected rows are `target_segments`, every other
|
|
241
|
+
* row rides as a LOCKED `fixed_segment` so the generator can see the whole timeline
|
|
242
|
+
* and write into it coherently. Returns null when nothing is selected — the caller
|
|
243
|
+
* must not fire a call the backend would 400 for being empty.
|
|
244
|
+
*/
|
|
245
|
+
export function buildSpreadBody(opts: BuildSpreadOptions): SpreadRequestWire | null {
|
|
246
|
+
const targets = opts.goals.filter((g) => opts.selectedKeys.has(g.key));
|
|
247
|
+
if (targets.length === 0) return null;
|
|
248
|
+
const fixed = opts.goals.filter((g) => !opts.selectedKeys.has(g.key));
|
|
249
|
+
const body: SpreadRequestWire = {
|
|
250
|
+
mode: "spread",
|
|
251
|
+
fixed_segments: fixed.map(segmentRef),
|
|
252
|
+
target_segments: targets.map(segmentRef),
|
|
253
|
+
};
|
|
254
|
+
put(body, "movie_query", opts.movieQuery);
|
|
255
|
+
const negatives = opts.globalNegative
|
|
256
|
+
.split(",")
|
|
257
|
+
.map((s) => s.trim())
|
|
258
|
+
.filter((s) => s !== "");
|
|
259
|
+
if (negatives.length) body.global_negative = negatives;
|
|
260
|
+
if (opts.steeringSeed != null && Number.isInteger(opts.steeringSeed)) {
|
|
261
|
+
body.steering_seed = opts.steeringSeed;
|
|
262
|
+
}
|
|
263
|
+
if (opts.styleBible && Object.keys(opts.styleBible).length) {
|
|
264
|
+
body.style_bible = opts.styleBible;
|
|
265
|
+
}
|
|
266
|
+
if (opts.context && Object.keys(opts.context).length) body.context = opts.context;
|
|
267
|
+
return body;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── response readers (tolerant; a bad shape changes NOTHING) ────────────────
|
|
271
|
+
function strArray(value: unknown): string[] {
|
|
272
|
+
return Array.isArray(value) ? value.filter((x): x is string => typeof x === "string") : [];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function str(value: unknown): string {
|
|
276
|
+
return typeof value === "string" ? value : "";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Read a spread 200 body. Returns null if it is not a spread reply at all (the
|
|
281
|
+
* caller then reports a malformed response rather than applying anything). Rows
|
|
282
|
+
* without a usable `segment_id` + `prompt` are DROPPED, never defaulted.
|
|
283
|
+
*/
|
|
284
|
+
export function readSpreadResult(value: unknown): SpreadResult | null {
|
|
285
|
+
if (value == null || typeof value !== "object") return null;
|
|
286
|
+
const v = value as Record<string, unknown>;
|
|
287
|
+
if (!Array.isArray(v.segments)) return null;
|
|
288
|
+
const segments: SpreadSegmentResult[] = [];
|
|
289
|
+
for (const raw of v.segments) {
|
|
290
|
+
if (raw == null || typeof raw !== "object") continue;
|
|
291
|
+
const r = raw as Record<string, unknown>;
|
|
292
|
+
const id = str(r.segment_id).trim();
|
|
293
|
+
const prompt = str(r.prompt).trim();
|
|
294
|
+
if (!id || !prompt) continue;
|
|
295
|
+
segments.push({
|
|
296
|
+
segment_id: id,
|
|
297
|
+
operation: str(r.operation) || "generate",
|
|
298
|
+
prompt,
|
|
299
|
+
negative: str(r.negative).trim(),
|
|
300
|
+
continuity_note: str(r.continuity_note).trim(),
|
|
301
|
+
directions_used: Array.isArray(r.directions_used) ? r.directions_used : [],
|
|
302
|
+
warnings: strArray(r.warnings),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
const seed = v.steering_seed;
|
|
306
|
+
return {
|
|
307
|
+
segments,
|
|
308
|
+
missing_segments: strArray(v.missing_segments),
|
|
309
|
+
warnings: strArray(v.warnings),
|
|
310
|
+
invented_identity_attributes: strArray(v.invented_identity_attributes),
|
|
311
|
+
steering:
|
|
312
|
+
v.steering != null && typeof v.steering === "object"
|
|
313
|
+
? (v.steering as Record<string, unknown>)
|
|
314
|
+
: {},
|
|
315
|
+
steering_seed: typeof seed === "number" && Number.isInteger(seed) ? seed : null,
|
|
316
|
+
model: typeof v.model === "string" ? v.model : null,
|
|
317
|
+
model_requested: typeof v.model_requested === "string" ? v.model_requested : null,
|
|
318
|
+
model_resolved: typeof v.model_resolved === "string" ? v.model_resolved : null,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Read an intent 200 body. Anything unrecognized reads as a DEGRADED `ambiguous`,
|
|
324
|
+
* which the UI renders as "choose an action" — the same, safe, both-buttons state
|
|
325
|
+
* a real router outage produces. There is no failure mode here that arms a button.
|
|
326
|
+
*/
|
|
327
|
+
export function readIntentResult(value: unknown): IntentResult {
|
|
328
|
+
const degraded: IntentResult = {
|
|
329
|
+
intent: "ambiguous",
|
|
330
|
+
operation: null,
|
|
331
|
+
confidence: 0,
|
|
332
|
+
cached: false,
|
|
333
|
+
degraded: true,
|
|
334
|
+
};
|
|
335
|
+
if (value == null || typeof value !== "object") return degraded;
|
|
336
|
+
const v = value as Record<string, unknown>;
|
|
337
|
+
const name = v.intent;
|
|
338
|
+
if (name !== "empty" && name !== "direction" && name !== "scene_prompt" && name !== "ambiguous") {
|
|
339
|
+
return degraded;
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
intent: name,
|
|
343
|
+
operation: typeof v.operation === "string" ? v.operation : null,
|
|
344
|
+
confidence: typeof v.confidence === "number" ? v.confidence : 0,
|
|
345
|
+
cached: v.cached === true,
|
|
346
|
+
degraded: v.degraded === true,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The non-destructive notice a spread produces: what changed, what did NOT, and
|
|
352
|
+
* every warning the backend surfaced. Rendered as a dismissible panel — the spec's
|
|
353
|
+
* "surface warnings/missing/invented non-destructively", i.e. never as an overwrite
|
|
354
|
+
* and never swallowed.
|
|
355
|
+
*/
|
|
356
|
+
export function spreadNoticeLines(result: SpreadResult, appliedKeys: string[]): string[] {
|
|
357
|
+
const lines: string[] = [];
|
|
358
|
+
lines.push(
|
|
359
|
+
appliedKeys.length === 1
|
|
360
|
+
? "1 segment rewritten."
|
|
361
|
+
: `${appliedKeys.length} segments rewritten.`,
|
|
362
|
+
);
|
|
363
|
+
if (result.missing_segments.length) {
|
|
364
|
+
lines.push(
|
|
365
|
+
`The generator did not write ${result.missing_segments.length} selected ` +
|
|
366
|
+
"row(s) — they are unchanged.",
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
if (result.invented_identity_attributes.length) {
|
|
370
|
+
lines.push(
|
|
371
|
+
"⚠ Invented identity attributes reported: " +
|
|
372
|
+
result.invented_identity_attributes.join(", ") +
|
|
373
|
+
" — check these against your identity profile.",
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
for (const w of result.warnings) lines.push(w);
|
|
377
|
+
for (const s of result.segments) {
|
|
378
|
+
for (const w of s.warnings) lines.push(`${s.segment_id}: ${w}`);
|
|
379
|
+
}
|
|
380
|
+
// PROVENANCE (§1f): say plainly when another model answered.
|
|
381
|
+
if (
|
|
382
|
+
result.model_requested &&
|
|
383
|
+
result.model_resolved &&
|
|
384
|
+
result.model_requested !== result.model_resolved
|
|
385
|
+
) {
|
|
386
|
+
lines.push(
|
|
387
|
+
`⚠ Answered by ${result.model_resolved}, not the requested ` +
|
|
388
|
+
`${result.model_requested}.`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
return lines;
|
|
392
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Tiny seam letting the shell-level session-library sidebar STAGE a source clip
|
|
2
|
+
// into the Studio Clips station's generate form while (or before) it is mounted —
|
|
3
|
+
// the B2 movie->studio chain's "Send to Studio". It mirrors composerBridge exactly
|
|
4
|
+
// but for a DIFFERENT station with a DIFFERENT staging contract: the Generate
|
|
5
|
+
// composer takes ordered text/media PARTS (composerBridge), whereas the studio
|
|
6
|
+
// generate form takes ONE prior-tier VIDEO to extend. Keeping them as two small,
|
|
7
|
+
// self-contained seams (each registered by its own station) is cleaner than
|
|
8
|
+
// overloading composerBridge's StageRequest with a studio mode — the handlers,
|
|
9
|
+
// payloads, and mounting stations share nothing.
|
|
10
|
+
//
|
|
11
|
+
// Single-handler by design: exactly one StudioClipsStation is ever mounted (the
|
|
12
|
+
// /studio-clips route). Because "Send to Studio" fires from the shell sidebar on
|
|
13
|
+
// ANY route — before Studio Clips has mounted — a stage request raised while no
|
|
14
|
+
// handler is registered is BUFFERED and drained the instant the station registers
|
|
15
|
+
// (the sidebar navigates to /studio-clips alongside the call). No deps; never
|
|
16
|
+
// touches server media.
|
|
17
|
+
import type { MediaRef } from "./contract";
|
|
18
|
+
import type { StudioItemProvenance } from "./mediaLibrary";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How the studio should consume the staged clip (slice (a) / v2v):
|
|
22
|
+
* • "extend" — i2v: continue the clip FROM ITS LAST FRAME (the original B2 flow).
|
|
23
|
+
* • "restyle" — v2v: repaint/transform the WHOLE clip via the VACE control model.
|
|
24
|
+
* Capability-aware "Send to Studio": the sidebar can pre-pick a mode, and the
|
|
25
|
+
* Studio Clips station also lets the user toggle it before generating.
|
|
26
|
+
*/
|
|
27
|
+
export type StudioMode = "extend" | "restyle";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A staged studio request: the prior-tier clip (a movie/scene/studio video ref)
|
|
31
|
+
* to extend or restyle. The station derives the POST body from it — `source_video`
|
|
32
|
+
* = the ref's abs-path uri (jail-resolved by the route), falling back to
|
|
33
|
+
* `source_asset_id` = the ref's asset_id (resolved via the media catalog). Every
|
|
34
|
+
* field optional + additive so the shape can grow without breaking callers.
|
|
35
|
+
*/
|
|
36
|
+
export interface StudioStageRequest {
|
|
37
|
+
sourceVideo?: MediaRef;
|
|
38
|
+
/** Preferred consume mode; the station defaults to "extend" when omitted. */
|
|
39
|
+
mode?: StudioMode;
|
|
40
|
+
/** Optional provenance forwarded from the source (e.g. the library item behind a
|
|
41
|
+
* "Send to Studio" / "Restyle") so the receiving surface can mint a first-class
|
|
42
|
+
* StudioItem carrying it. Purely additive — existing callers that omit it are
|
|
43
|
+
* unaffected. */
|
|
44
|
+
provenance?: StudioItemProvenance;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type StudioStageHandler = (req: StudioStageRequest) => void;
|
|
48
|
+
// A STACK of registered handlers (mount-aware). There are now TWO surfaces that can
|
|
49
|
+
// host the studio generate form — the standalone Studio station's Generate tab and
|
|
50
|
+
// the Generate station's "studio" mode — so a single overwriteable slot could let
|
|
51
|
+
// two mounts fight. The stack makes the seam robust: the MOST-RECENTLY registered
|
|
52
|
+
// mount is the active target, and on unmount its handler is removed (restoring the
|
|
53
|
+
// previous one as active) rather than blindly nulling a slot another mount may own.
|
|
54
|
+
// In practice the two surfaces live on different routes and are never mounted at the
|
|
55
|
+
// same time, but this keeps the contract correct regardless of mount order.
|
|
56
|
+
let handlers: StudioStageHandler[] = [];
|
|
57
|
+
let pending: StudioStageRequest[] = [];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A studio surface registers its stager while mounted. On register we drain any
|
|
61
|
+
* buffered request (raised from the sidebar before any surface mounted). Returns an
|
|
62
|
+
* unregister that removes THIS handler (not whatever is currently on top).
|
|
63
|
+
*/
|
|
64
|
+
export function registerStudioStager(fn: StudioStageHandler): () => void {
|
|
65
|
+
handlers.push(fn);
|
|
66
|
+
if (pending.length > 0) {
|
|
67
|
+
const queued = pending;
|
|
68
|
+
pending = [];
|
|
69
|
+
for (const req of queued) fn(req);
|
|
70
|
+
}
|
|
71
|
+
return () => {
|
|
72
|
+
handlers = handlers.filter((h) => h !== fn);
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Ask the active mounted studio surface to apply `req`. Buffered if none is mounted
|
|
77
|
+
* yet (drained by the next surface that registers). */
|
|
78
|
+
export function requestStudioStage(req: StudioStageRequest): void {
|
|
79
|
+
const active = handlers[handlers.length - 1];
|
|
80
|
+
if (active) active(req);
|
|
81
|
+
else pending.push(req);
|
|
82
|
+
}
|