@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.
Files changed (177) hide show
  1. package/LICENSE +41 -0
  2. package/assets/sampels/images/bycycle/bd8603bad35d4f179f4d1134d8faf2ee.mp4 +0 -0
  3. package/assets/sampels/images/bycycle/frame_00000.png +0 -0
  4. package/assets/sampels/images/bycycle/frame_00001.png +0 -0
  5. package/assets/sampels/images/bycycle/frame_00002.png +0 -0
  6. package/assets/sampels/images/bycycle/frame_00003.png +0 -0
  7. package/assets/sampels/images/bycycle/frame_00004.png +0 -0
  8. package/assets/sampels/images/bycycle/frame_00005.png +0 -0
  9. package/assets/sampels/images/forrest/ff23090611b844a6b85c224f388bd542.mp4 +0 -0
  10. package/assets/sampels/images/forrest/frame_00000.png +0 -0
  11. package/assets/sampels/images/forrest/frame_00001.png +0 -0
  12. package/assets/sampels/images/forrest/frame_00002.png +0 -0
  13. package/assets/sampels/images/forrest/frame_00003.png +0 -0
  14. package/assets/sampels/images/forrest/frame_00004.png +0 -0
  15. package/assets/sampels/images/forrest/frame_00005.png +0 -0
  16. package/assets/sampels/images/shenzo/e1a4fcd623084ab4a986d11c8cd00db8.mp4 +0 -0
  17. package/assets/sampels/images/shenzo/frame_00000.png +0 -0
  18. package/assets/sampels/images/shenzo/frame_00001.png +0 -0
  19. package/assets/sampels/images/shenzo/frame_00002.png +0 -0
  20. package/assets/sampels/images/shenzo/frame_00003.png +0 -0
  21. package/assets/sampels/images/shenzo/frame_00004.png +0 -0
  22. package/assets/sampels/images/shenzo/frame_00005.png +0 -0
  23. package/assets/sampels/scenes/man.mp4 +0 -0
  24. package/assets/sampels/scenes/woman.mp4 +0 -0
  25. package/assets/sampels/studio_movies/caverns/_assembly/contrib_00.mp4 +0 -0
  26. package/assets/sampels/studio_movies/caverns/movie.json +67 -0
  27. package/assets/sampels/studio_movies/caverns/movie.mp4 +0 -0
  28. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/clip.mp4 +0 -0
  29. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/manifest.json +87 -0
  30. package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/provenance.json +7 -0
  31. package/assets/sampels/studio_movies/caverns/segment_01/branch.png +0 -0
  32. package/assets/sampels/studio_movies/keeper-matrix/_assembly/concat_list.txt +2 -0
  33. package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_00.mp4 +0 -0
  34. package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_01.mp4 +0 -0
  35. package/assets/sampels/studio_movies/keeper-matrix/movie.json +81 -0
  36. package/assets/sampels/studio_movies/keeper-matrix/movie.mp4 +0 -0
  37. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/clip.mp4 +0 -0
  38. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/manifest.json +87 -0
  39. package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/provenance.json +7 -0
  40. package/assets/sampels/studio_movies/keeper-matrix/segment_01/branch.png +0 -0
  41. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/clip.mp4 +0 -0
  42. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/manifest.json +87 -0
  43. package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/provenance.json +7 -0
  44. package/assets/sampels/videos/a_red_paper_boat_drifting_on_a_calm_pond__gentle_ripples__mo_a62d6c28.mp4 +0 -0
  45. package/assets/sampels/videos/aurora.mp4 +0 -0
  46. package/assets/sampels/videos/dark_night.mp4 +0 -0
  47. package/assets/sampels/videos/fire_and_soul.mp4 +0 -0
  48. package/assets/sampels/videos/harsh_seas.mp4 +0 -0
  49. package/entry.tsx +74 -0
  50. package/index.html +53 -0
  51. package/package.json +42 -0
  52. package/src/assets/hugpy-mark.png +0 -0
  53. package/src/config.ts +676 -0
  54. package/src/demo/DemoBanner.tsx +54 -0
  55. package/src/demo/assets/demo-audio.mp3 +0 -0
  56. package/src/demo/assets/demo-clip.mp4 +0 -0
  57. package/src/demo/assets/demo-crop.mp4 +0 -0
  58. package/src/demo/assets/demo-poster.jpg +0 -0
  59. package/src/demo/assets/frame-1.jpg +0 -0
  60. package/src/demo/assets/frame-2.jpg +0 -0
  61. package/src/demo/assets/frame-3.jpg +0 -0
  62. package/src/demo/assets/frame-4.jpg +0 -0
  63. package/src/demo/assets/frame-5.jpg +0 -0
  64. package/src/demo/assets/frame-6.jpg +0 -0
  65. package/src/demo/assets/gen-1.jpg +0 -0
  66. package/src/demo/assets/gen-2.jpg +0 -0
  67. package/src/demo/assets.d.ts +14 -0
  68. package/src/demo/demo.css +64 -0
  69. package/src/demo/demoFetch.ts +305 -0
  70. package/src/demo/fixtures.ts +814 -0
  71. package/src/demo/index.ts +43 -0
  72. package/src/demo/mode.ts +81 -0
  73. package/src/demo/promptAssist.ts +181 -0
  74. package/src/demo/seed.ts +97 -0
  75. package/src/nav/BrandMark.css +66 -0
  76. package/src/nav/BrandMark.tsx +40 -0
  77. package/src/nav/Navbar.css +13 -0
  78. package/src/nav/Navbar.tsx +68 -0
  79. package/src/regions/SpatialRegionEditor.tsx +375 -0
  80. package/src/regions/TemporalRegionEditor.tsx +485 -0
  81. package/src/regions/types.ts +85 -0
  82. package/src/session.ts +85 -0
  83. package/src/share.ts +77 -0
  84. package/src/stations/ActivePanel.tsx +55 -0
  85. package/src/stations/ActiveProcessesStation.tsx +272 -0
  86. package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +228 -0
  87. package/src/stations/AddFileBar.tsx +59 -0
  88. package/src/stations/AudioCropStation.tsx +157 -0
  89. package/src/stations/ComfyPanel.tsx +210 -0
  90. package/src/stations/ComfyStation.tsx +196 -0
  91. package/src/stations/CondensedKnobStrip.tsx +245 -0
  92. package/src/stations/ConsolePanel.tsx +82 -0
  93. package/src/stations/FrameExtractStation.tsx +148 -0
  94. package/src/stations/GenIdentityBar.tsx +412 -0
  95. package/src/stations/GenerateStation.tsx +5603 -0
  96. package/src/stations/GenerateStation.tsx.bak-tester-removal +5170 -0
  97. package/src/stations/GoalComposer.tsx +139 -0
  98. package/src/stations/ImageCropStation.tsx +153 -0
  99. package/src/stations/KnobInfo.tsx +64 -0
  100. package/src/stations/PlaceholderStation.tsx +26 -0
  101. package/src/stations/SectionTabs.tsx +276 -0
  102. package/src/stations/SharePanel.tsx +298 -0
  103. package/src/stations/SidebarRegistry.tsx +105 -0
  104. package/src/stations/StationShell.tsx +96 -0
  105. package/src/stations/StudioClipsStation.tsx +28 -0
  106. package/src/stations/WorkbenchDrawer.tsx +66 -0
  107. package/src/stations/WorkbenchSidebar.tsx +1224 -0
  108. package/src/stations/WorkbenchStation.tsx +91 -0
  109. package/src/stations/comfyEmbed.ts +79 -0
  110. package/src/stations/drawerShell.tsx +419 -0
  111. package/src/stations/ops/AudioCropCore.tsx +218 -0
  112. package/src/stations/ops/CharacterGroupsPanel.tsx +716 -0
  113. package/src/stations/ops/FrameExtractCore.tsx +695 -0
  114. package/src/stations/ops/ImageCropCore.tsx +164 -0
  115. package/src/stations/ops/VideoCropCore.tsx +193 -0
  116. package/src/stations/ops/useVideoCropJobs.ts +224 -0
  117. package/src/stations/pollCaps.ts +87 -0
  118. package/src/stations/registry.ts +191 -0
  119. package/src/stations/studio/AngleRingEditor.tsx +159 -0
  120. package/src/stations/studio/IdentitiesStation.tsx +2455 -0
  121. package/src/stations/studio/IdentityProfileControls.tsx +262 -0
  122. package/src/stations/studio/StudioActiveProcesses.tsx +224 -0
  123. package/src/stations/studio/StudioAssistLogPanel.tsx +220 -0
  124. package/src/stations/studio/StudioGenerateMode.tsx +29 -0
  125. package/src/stations/studio/StudioGenerateTab.tsx +2688 -0
  126. package/src/stations/studio/StudioItemCard.tsx +334 -0
  127. package/src/stations/studio/StudioLibraryTab.tsx +506 -0
  128. package/src/stations/studio/StudioMovieComposer.tsx +2875 -0
  129. package/src/stations/studio/StudioMovieComposer.tsx.bak-sessions-20260812 +2729 -0
  130. package/src/stations/studio/StudioMovieSessions.tsx +553 -0
  131. package/src/stations/studio/StudioMovieSessions.tsx.bak-sessions-20260812 +417 -0
  132. package/src/stations/studio/StudioPlane.tsx +302 -0
  133. package/src/stations/studio/StudioSplicedRow.tsx +476 -0
  134. package/src/stations/studio/StudioViewer.tsx +339 -0
  135. package/src/stations/studio/TurntableViewer.tsx +220 -0
  136. package/src/stations/studio/movieTimeline.ts +198 -0
  137. package/src/stations/studio/promptCard.tsx +498 -0
  138. package/src/stations/studio/studioItem.ts +87 -0
  139. package/src/stations/studio/studioShared.tsx +821 -0
  140. package/src/stations/studio/useIdentityProfiles.ts +575 -0
  141. package/src/stations/studio/useReconstruction.ts +283 -0
  142. package/src/stations/types.ts +43 -0
  143. package/src/stations/useAudioCropJobs.ts +202 -0
  144. package/src/stations/useAudioExtractJob.ts +144 -0
  145. package/src/stations/useCropJobs.ts +206 -0
  146. package/src/stations/useFrameJobs.ts +140 -0
  147. package/src/stations/useGenerateJob.ts +151 -0
  148. package/src/stations/useGenerateMovieJob.ts +177 -0
  149. package/src/stations/useGenerateRowJobs.ts +184 -0
  150. package/src/stations/useGenerateSceneJob.ts +156 -0
  151. package/src/stations/useGenerateSceneRowJobs.ts +282 -0
  152. package/src/style/app.css +4974 -0
  153. package/src/transport/client.ts +262 -0
  154. package/src/transport/telemetry.ts +97 -0
  155. package/src/video/DropReceptacle.tsx +122 -0
  156. package/src/video/PromptAssistButtons.tsx +118 -0
  157. package/src/video/composerBridge.ts +66 -0
  158. package/src/video/contract.ts +599 -0
  159. package/src/video/jobTracker.ts +702 -0
  160. package/src/video/mediaLibrary.ts +328 -0
  161. package/src/video/renderCompat.ts +512 -0
  162. package/src/video/sessionForm.ts +189 -0
  163. package/src/video/spreadAssist.ts +392 -0
  164. package/src/video/studioBridge.ts +82 -0
  165. package/src/video/useMediaJobs.ts +279 -0
  166. package/src/video/useModels.ts +413 -0
  167. package/src/video/useMoviePresets.ts +97 -0
  168. package/src/video/useMovieSessions.ts +292 -0
  169. package/src/video/usePresets.ts +167 -0
  170. package/src/video/useProjects.ts +87 -0
  171. package/src/video/usePromptAssist.ts +394 -0
  172. package/src/video/useRenderPresets.ts +221 -0
  173. package/src/video/useStudioAssistLog.ts +266 -0
  174. package/src/video/useStudioPresets.ts +128 -0
  175. package/src/video/useWorkerCalls.ts +165 -0
  176. package/tsconfig.json +17 -0
  177. 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
+ }