@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,302 @@
1
+ // The single STUDIO WORKBENCH (Round 9) — the movie-page shape with ONE shared left
2
+ // sidebar. The studio no longer carries its own [ Library | Active | Settings ] sidebar
3
+ // (that produced the dual-sidebar redundancy): it now folds into the arm shell's ONE
4
+ // WorkbenchSidebar via the sidebar registry —
5
+ //
6
+ // • Settings — the geometry / sampler / model knobs + the template dropdown render
7
+ // into the shell sidebar's guarded "Settings" tab (the studio surface portals its
8
+ // knob grid into `reg.settingsHost`; the knob STATE stays in the surface).
9
+ // • Active — studio's in-flight renders portal into the shell "Active Processes"
10
+ // tab (studio jobs are not in the jobTracker, so they ride in as an extra section).
11
+ // • Library — the shell "Session Library" IS the one library; produced studio clips
12
+ // already flow into it (genKind studio_i2v) with extend ("Send to Studio") / restyle
13
+ // ("Restyle") on every clip row. (The former inline StudioLibrarySection is retained
14
+ // in the tree, unused, for history — per-row Details / capability+model filters stay
15
+ // deferred there. select-to-viewer is NO LONGER deferred: it exists in that dead
16
+ // file's code but was never reachable from either mount, which is exactly why the
17
+ // viewer read as stuck — see StudioLibraryTab.tsx's header note. The reachable
18
+ // version now lives on the viewer itself, below.)
19
+ //
20
+ // So the CENTER column is just the movie skeleton: the VIEWER on top (plays the selected
21
+ // clip, with its own header controls — Play from library / Clear / follow-newest, operator
22
+ // ask 2026-07-12 — so it never reads as a fixed, unchangeable default) → the GENERATOR
23
+ // below it (composer prompt + negative, with the shape heroes / reference slots / attach
24
+ // affordances as the input block, hero gating intact) → the Run row. Template selection
25
+ // lives in the Settings dropdown.
26
+ //
27
+ // This ONE component mounts in BOTH places the studio appears — the standalone Studio station
28
+ // (StudioClipsStation) and the Generate station's "studio" mode (StudioGenerateMode) — so the
29
+ // workbench is identical and a job enqueued from either is byte-identical. The only per-mount
30
+ // difference is `registerBridge` (the standalone station owns the "Send to Studio" target).
31
+ import { useCallback, useEffect, useRef, useState } from "react";
32
+ // Sub-tab input memory for the staged source + item strip — see
33
+ // video/sessionForm.ts.
34
+ import { useSessionState } from "../../video/sessionForm";
35
+ import { createPortal } from "react-dom";
36
+ import { registerStudioStager, type StudioMode } from "../../video/studioBridge";
37
+ import { StudioAssistLogPanel } from "./StudioAssistLogPanel";
38
+ import type { MediaRef } from "../../video/contract";
39
+ import { useStudioPresets } from "../../video/useStudioPresets";
40
+ import { useStudioClips } from "./studioShared";
41
+ import {
42
+ makeStudioItem,
43
+ applyOp,
44
+ revertItem,
45
+ type StudioItem,
46
+ type StudioItemOp,
47
+ } from "./studioItem";
48
+ import { StudioItemCard } from "./StudioItemCard";
49
+ import { StudioGenerateSurface } from "./StudioGenerateTab";
50
+ import { StudioViewer } from "./StudioViewer";
51
+ import { useSidebarRegistry } from "../SidebarRegistry";
52
+
53
+ export interface StudioPlaneProps {
54
+ /** Register the studioBridge stager (the "Send to Studio" target). The standalone
55
+ * Studio station passes true; the Generate-station studio mode passes false — the
56
+ * shell library's "Send to Studio" navigates to /studio-clips (the standalone station),
57
+ * so registering in the Generate mode would only race that navigation. */
58
+ registerBridge?: boolean;
59
+ /** When set by a top-level Generate tab (Clip / Cinema), lock the generate surface to
60
+ * that sub-mode and hide its internal Clip|Cinema switcher. Omitted by the standalone
61
+ * /studio-clips station, which keeps the internal switcher. */
62
+ lockedSurfaceMode?: "clip" | "movie";
63
+ }
64
+
65
+ export function StudioPlane({ registerBridge = false, lockedSurfaceMode }: StudioPlaneProps) {
66
+ const reg = useSidebarRegistry();
67
+ const { presets, loading: presetsLoading } = useStudioPresets(true);
68
+ const clipsState = useStudioClips();
69
+
70
+ // Source clip + its staged consume mode; `stageSeq` bumps on each external stage so the
71
+ // surface adopts the capability exactly once per stage.
72
+ //
73
+ // The staged SOURCE and its mode are session-scoped (operator ask 2026-08-06:
74
+ // inputs survive a sub-tab switch) — they are operator-staged inputs to the
75
+ // generate surface below, and restoring the surface's capability/knobs while
76
+ // silently dropping the clip they were chosen for would be a half-restore.
77
+ // `stageSeq` deliberately is NOT persisted: it counts STAGE EVENTS, and the
78
+ // surface's capability-resolution effect keys off it. Restoring a non-zero
79
+ // value would re-fire that handoff on every mount and overwrite exactly the
80
+ // capability we just restored.
81
+ const [source, setSource] = useSessionState<MediaRef | null>("studio.plane.source", null);
82
+ const [sourceMode, setSourceMode] = useSessionState<StudioMode>(
83
+ "studio.plane.sourceMode",
84
+ "extend",
85
+ );
86
+ const [stageSeq, setStageSeq] = useState(0);
87
+
88
+ // First-class studio items (Studio redesign, slice 1). Each external stage mints a
89
+ // provenance-carrying StudioItem here. Slice 2b RENDERS these as a strip of
90
+ // StudioItemCards with inline per-item ops (crop / frames), but they still do NOT
91
+ // feed the generate surface — the legacy `source`/`setSource` wiring below is passed
92
+ // to StudioGenerateSurface exactly as before, so today's single-source behavior
93
+ // (extend vs restyle, cross-tier carry) is byte-identical. Slice 3 folds the surface
94
+ // into the item (a per-item composer) and makes generation item-driven.
95
+ const [items, setItems] = useSessionState<StudioItem[]>("studio.plane.items", []);
96
+
97
+ // LIFTED clip selection so the player-forward viewer (top of the centre column) follows
98
+ // the newest playable clip as a fresh enqueue completes — see `pinned` immediately
99
+ // below for the manual override (operator ask 2026-07-12: "can the change be made
100
+ // THROUGH to change the initial scene or video or make it 'new'" — the viewer used to
101
+ // have no controls of its own and would read as permanently stuck on one clip).
102
+ const [selected, setSelected] = useState<string | null>(null);
103
+
104
+ // PINNED = the operator has taken manual control of the viewer (a library pick via
105
+ // "▤ Play from library", or an explicit "✕ Clear viewer") — the auto-follow effect
106
+ // below stands down while this is true, so a manual choice sticks instead of being
107
+ // overwritten by the next 6s clip-list poll. "↻ follow newest" flips it back off and
108
+ // hands control back to auto-follow. This is the one semantic switch StudioViewer's
109
+ // header reads/toggles; see its matching half of this note.
110
+ const [pinned, setPinned] = useState(false);
111
+
112
+ const topRef = useRef<HTMLDivElement | null>(null);
113
+
114
+ // Round 9: fold into the ONE shell sidebar — reveal its guarded Settings tab (knobs +
115
+ // template dropdown portal there). registerSettings is stable, so this runs once per
116
+ // mount and unregisters on unmount.
117
+ //
118
+ // Studio's in-flight renders are NO LONGER portaled into the shared Active Processes
119
+ // tab: those same jobs now arrive fleet-wide via GET /llm/jobs (kinds studio_i2v /
120
+ // generate_studio_movie / generate_scene / generate_movie, transport "media"), which
121
+ // the panel polls and renders directly — so keeping the portal would double-count each
122
+ // studio render. The in-plane "Active renders" component (StudioActiveProcesses) is
123
+ // retained in the tree for history; it just no longer feeds the global sidebar.
124
+ useEffect(() => reg.registerSettings(), [reg.registerSettings]);
125
+
126
+ // GENERATION LOG → the sidebar's ACTIVE tab (operator, 2026-08-05): the
127
+ // studio-assist log rides the existing activeExtra portal (the always-rendered
128
+ // host at the foot of the Active Processes panel), so generation attempts sit
129
+ // with the in-flight renders — one "what is the studio doing" place. Register
130
+ // so the panel suppresses its empty copy while the log is mounted.
131
+ useEffect(() => reg.registerActiveExtra(), [reg.registerActiveExtra]);
132
+
133
+ // Auto-follow the newest playable clip WHILE UNPINNED. Previously this only re-picked
134
+ // when the selected job disappeared from the clip list entirely — since a completed
135
+ // job id never leaves the durable catalog, that meant it latched onto the FIRST
136
+ // playable clip of the session and then never moved again, no matter how many newer
137
+ // clips completed after it. That is the reported bug ("sits on the same clip
138
+ // indefinitely and reads as a stuck, unchangeable default"): the header comment always
139
+ // CLAIMED auto-follow, but the code only implemented sticky-keep-unless-deleted. Fixed:
140
+ // now it re-syncs to the newest playable clip on every clip-list change, same as the
141
+ // comment always claimed — gated on `pinned` so a manual pick/clear (see above) is
142
+ // respected instead of being clobbered by the next poll tick.
143
+ useEffect(() => {
144
+ if (pinned) return;
145
+ setSelected(clipsState.clips.find((c) => c.playable)?.job_id ?? null);
146
+ }, [clipsState.clips, pinned]);
147
+
148
+ // Manual pick from the viewer's "▤ Play from library" panel — pins so auto-follow
149
+ // leaves the choice alone.
150
+ const pickViewerClip = useCallback((jobId: string) => {
151
+ setSelected(jobId);
152
+ setPinned(true);
153
+ }, []);
154
+
155
+ // "✕ Clear viewer" — empties AND pins, so StudioViewer's explicit empty state
156
+ // ("nothing playing") sticks instead of being silently repopulated by the next poll
157
+ // (StudioViewer no longer has an "any playable clip" fallback that would undo this —
158
+ // see that file).
159
+ const clearViewer = useCallback(() => {
160
+ setSelected(null);
161
+ setPinned(true);
162
+ }, []);
163
+
164
+ // "↻ follow newest" toggle — flips `pinned`. Turning it back on (true → false) hands
165
+ // control back to the auto-follow effect above, which fires on the very next render
166
+ // and re-syncs to the newest playable clip immediately.
167
+ const toggleFollowNewest = useCallback(() => {
168
+ setPinned((v) => !v);
169
+ }, []);
170
+
171
+ const applyStage = useCallback((ref: MediaRef | null, mode: StudioMode) => {
172
+ setSource(ref);
173
+ setSourceMode(mode);
174
+ setStageSeq((n) => n + 1);
175
+ // Bring the generator into view so the staged source shows.
176
+ topRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
177
+ }, []);
178
+
179
+ // ── Slice-2b item mutation (matched by the item's stable `key`, NOT its uri —
180
+ // a ref may legitimately repeat) ────────────────────────────────────────────
181
+
182
+ // SAME-KIND op → replace the matching item's `working` via applyOp (mutate in place).
183
+ const onApplyOp = useCallback((target: StudioItem, op: StudioItemOp) => {
184
+ setItems((prev) =>
185
+ prev.map((it) => (it.key === target.key ? applyOp(it, op) : it)),
186
+ );
187
+ }, []);
188
+
189
+ // KIND-CHANGE / FAN-OUT op → append a NEW child item per produced ref, carrying the
190
+ // forward-only provenance edge (parentUri = the parent's working uri) + the op kind
191
+ // as origin, and inheriting any prompt/model/genKind lineage from the parent.
192
+ const onSpawn = useCallback(
193
+ (parent: StudioItem, produced: MediaRef[], kind: StudioItemOp["kind"]) => {
194
+ if (produced.length === 0) return;
195
+ setItems((prev) => [
196
+ ...prev,
197
+ ...produced.map((ref) =>
198
+ makeStudioItem(ref, {
199
+ origin: `op:${kind}`,
200
+ parentUri: parent.working.uri,
201
+ ...(parent.provenance.prompt != null
202
+ ? { prompt: parent.provenance.prompt }
203
+ : {}),
204
+ ...(parent.provenance.model != null
205
+ ? { model: parent.provenance.model }
206
+ : {}),
207
+ ...(parent.provenance.genKind != null
208
+ ? { genKind: parent.provenance.genKind }
209
+ : {}),
210
+ }),
211
+ ),
212
+ ]);
213
+ },
214
+ [],
215
+ );
216
+
217
+ // Revert the item's `working` back to its immutable source (ops history preserved).
218
+ const onRevert = useCallback((target: StudioItem) => {
219
+ setItems((prev) =>
220
+ prev.map((it) => (it.key === target.key ? revertItem(it) : it)),
221
+ );
222
+ }, []);
223
+
224
+ // Forget one item's card (local only — never deletes the underlying server asset).
225
+ const onRemove = useCallback((target: StudioItem) => {
226
+ setItems((prev) => prev.filter((it) => it.key !== target.key));
227
+ }, []);
228
+
229
+ // studioBridge "Send to Studio" / "Restyle": only the registering mount (the standalone
230
+ // station) receives staged sources; the Generate-mode mount passes registerBridge=false.
231
+ useEffect(() => {
232
+ if (!registerBridge) return;
233
+ return registerStudioStager((req) => {
234
+ const ref = req.sourceVideo ?? null;
235
+ applyStage(ref, req.mode ?? "extend");
236
+ // Mint the first-class StudioItem for this handoff, carrying the forwarded
237
+ // provenance (or a "send-to-studio" default). Additive — see the `items` note.
238
+ if (ref) {
239
+ const provenance = req.provenance ?? { origin: "send-to-studio", parentUri: ref.uri };
240
+ setItems((prev) => [...prev, makeStudioItem(ref, provenance)]);
241
+ }
242
+ });
243
+ }, [registerBridge, applyStage]);
244
+
245
+ return (
246
+ <div className="vi-studio-workbench" ref={topRef}>
247
+ {/* CENTER, movie skeleton: viewer on top → generator below. Viewer header controls
248
+ (Play from library / Clear / follow-newest toggle) are wired here so both
249
+ plane mounts get them for free — see the `pinned` note above. */}
250
+ <StudioViewer
251
+ clips={clipsState.clips}
252
+ selected={selected}
253
+ pinned={pinned}
254
+ onPick={pickViewerClip}
255
+ onClear={clearViewer}
256
+ onToggleFollow={toggleFollowNewest}
257
+ />
258
+
259
+ {/* Slice 2b: the staged studio items as a strip of first-class cards with inline
260
+ per-item ops. Additive — it sits ABOVE the generate surface and does not touch
261
+ the surface or its props (slice 3 folds these together). Rendered in BOTH plane
262
+ mounts identically (this is the one shared StudioPlane). Each card is wrapped in
263
+ a keyed intrinsic <div> so the map key never lands on a component (which the
264
+ bare-tsc env flags as a spurious prop). */}
265
+ {items.length > 0 && (
266
+ <div className="vi-studio-item-strip" aria-label="Studio items">
267
+ {items.map((it) => (
268
+ <div className="vi-studio-item-cell" key={it.key}>
269
+ <StudioItemCard
270
+ item={it}
271
+ onApplyOp={onApplyOp}
272
+ onSpawn={onSpawn}
273
+ onRevert={onRevert}
274
+ onRemove={onRemove}
275
+ />
276
+ </div>
277
+ ))}
278
+ </div>
279
+ )}
280
+
281
+ <StudioGenerateSurface
282
+ presets={presets}
283
+ presetsLoading={presetsLoading}
284
+ clips={clipsState.clips}
285
+ source={source}
286
+ setSource={setSource}
287
+ sourceMode={sourceMode}
288
+ stageSeq={stageSeq}
289
+ onEnqueued={clipsState.refresh}
290
+ settingsHost={reg.settingsHost}
291
+ lockedSurfaceMode={lockedSurfaceMode}
292
+ />
293
+ {/* STUDIO-ASSIST LIVE LOG → portaled into the sidebar's ACTIVE (Active
294
+ Processes) tab via the registry's activeExtra host (operator correction
295
+ 2026-08-05: "active tab" = Active Processes). Single mount, shared stream. */}
296
+ {reg.activeExtraHost ? createPortal(<StudioAssistLogPanel />, reg.activeExtraHost) : null}
297
+ {/* Studio's in-flight renders are shown by the shared Active Processes panel via the
298
+ fleet-wide GET /llm/jobs feed (no per-plane portal — that would double-count each
299
+ render, since the same job appears in /llm/jobs with transport "media"). */}
300
+ </div>
301
+ );
302
+ }