@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,334 @@
1
+ // StudioItemCard — the first VISIBLE piece of the Studio redesign (slice 2b). It
2
+ // renders a first-class StudioItem inline with its own per-item OPS, so a staged
3
+ // studio item is no longer a loose `source` ref but a card that shows explicit,
4
+ // kind-gated operations (Image Crop / Video Crop / Audio Crop / Frames & Models)
5
+ // which either MUTATE the item in place or BRANCH a new child item.
6
+ //
7
+ // The card reuses the slice-2a op "cores" verbatim (ImageCropCore / VideoCropCore /
8
+ // AudioCropCore / FrameExtractCore): selecting an op opens the matching Core in an
9
+ // inline drawer, wired via each Core's additive `onProduced` sink. The card contains
10
+ // NO composer (prompt / negative / parts) — that is slice 3's per-item composer,
11
+ // deliberately kept out here to bound the recursion. Ops only.
12
+ //
13
+ // The DAG rule (routing): an op whose PRODUCED media kind equals the item's working
14
+ // kind (image item + Image Crop; video item + Video Crop; audio item + Audio Crop) is
15
+ // SAME-KIND → onApplyOp replaces the parent's `working`. An op that CHANGES the kind
16
+ // or FANS OUT (video item + Audio Crop → audio; video item + Frames → images) →
17
+ // onSpawn mints NEW child items and never overwrites the parent's `working` (that
18
+ // would corrupt its kind).
19
+ import { useRef, useState, type ReactNode } from "react";
20
+ import { mediaBytesUrl } from "../../config";
21
+ import type { MediaRef } from "../../video/contract";
22
+ import type { SpatialRegion, TemporalRegion } from "../../regions/types";
23
+ import { getJobs, type StationId } from "../../video/jobTracker";
24
+ import { updateLibraryProvenance } from "../../video/mediaLibrary";
25
+ import type { StudioItem, StudioItemOp } from "./studioItem";
26
+ import { ImageCropCore } from "../ops/ImageCropCore";
27
+ import { VideoCropCore } from "../ops/VideoCropCore";
28
+ import { AudioCropCore } from "../ops/AudioCropCore";
29
+ import { FrameExtractCore } from "../ops/FrameExtractCore";
30
+
31
+ type OpKind = StudioItemOp["kind"];
32
+
33
+ /** A produced ref + the axes the op ran over — the shape every Core's onProduced
34
+ * hands us (the frame sink omits `meta`, which is why it is optional). */
35
+ type ProducedMeta = { spatial?: SpatialRegion | null; temporal?: TemporalRegion | null };
36
+ type ProducedSink = (op: OpKind, ref: MediaRef, meta?: ProducedMeta) => void;
37
+
38
+ // Fresh op id — mirrors the arm's monotonic-counter + base-36-timestamp idiom
39
+ // (studioItem.ts itemKey / useCropJobs.makeKey), unique within a session with no
40
+ // new dependency.
41
+ let _opSeq = 0;
42
+ function opKey(): string {
43
+ _opSeq += 1;
44
+ return `op_${_opSeq.toString(36)}_${Date.now().toString(36)}`;
45
+ }
46
+
47
+ // The media kind an op PRODUCES — the DAG routing pivot. When it equals the item's
48
+ // working kind the op mutates in place (same-kind); otherwise it branches a child.
49
+ function producedKind(op: OpKind): string {
50
+ switch (op) {
51
+ case "image_crop":
52
+ return "image";
53
+ case "video_crop":
54
+ return "video";
55
+ case "audio_crop":
56
+ return "audio";
57
+ case "frame_extract":
58
+ return "image";
59
+ }
60
+ }
61
+
62
+ // Which tracker station each op enqueues on — used to SEED the re-emit guard below.
63
+ const STATION_FOR_OP: Record<OpKind, StationId> = {
64
+ image_crop: "image-crop",
65
+ video_crop: "video-crop",
66
+ audio_crop: "audio-crop",
67
+ frame_extract: "frames",
68
+ };
69
+
70
+ const OP_LABEL: Record<OpKind, string> = {
71
+ image_crop: "Image Crop",
72
+ video_crop: "Video Crop",
73
+ audio_crop: "Audio Crop",
74
+ frame_extract: "Frames & Models",
75
+ };
76
+
77
+ // The ops offered for a given working kind (the menu gate).
78
+ // image → Image Crop · video → Video Crop / Audio Crop / Frames · audio → Audio Crop
79
+ function opsFor(kind: string): OpKind[] {
80
+ if (kind === "image") return ["image_crop"];
81
+ if (kind === "video") return ["video_crop", "audio_crop", "frame_extract"];
82
+ if (kind === "audio") return ["audio_crop"];
83
+ return [];
84
+ }
85
+
86
+ // Last path segment of a uri, for the compact (immutable) provenance line.
87
+ function shortUri(uri: string): string {
88
+ const clean = uri.split(/[?#]/)[0];
89
+ const seg = clean.split("/").filter(Boolean).pop() ?? uri;
90
+ return seg.length > 40 ? `…${seg.slice(-39)}` : seg;
91
+ }
92
+
93
+ // Poster/preview for the item's working ref, per kind — the arm's inline idiom (a
94
+ // muted seeked <video>, an <img>, or an <audio>), not the non-exported StudioGenerateTab
95
+ // SourceThumb (kept private to that surface).
96
+ function renderMedia(ref: MediaRef): ReactNode {
97
+ const src = mediaBytesUrl(ref.uri);
98
+ if (ref.kind === "image") {
99
+ return <img className="vi-studio-item-img" src={src} alt="Studio item" />;
100
+ }
101
+ if (ref.kind === "video") {
102
+ return (
103
+ <video
104
+ className="vi-studio-item-img"
105
+ src={`${src}#t=0.1`}
106
+ muted
107
+ playsInline
108
+ controls
109
+ preload="metadata"
110
+ aria-label="Studio item preview"
111
+ />
112
+ );
113
+ }
114
+ if (ref.kind === "audio") {
115
+ return (
116
+ <audio
117
+ className="vi-studio-item-audio"
118
+ src={src}
119
+ controls
120
+ preload="metadata"
121
+ aria-label="Studio item preview"
122
+ />
123
+ );
124
+ }
125
+ return <span className="vi-crop-hint">No preview for {ref.kind}.</span>;
126
+ }
127
+
128
+ // The immutable "where it came from" line — read-only, never editable.
129
+ function ProvenanceLine({ item }: { item: StudioItem }) {
130
+ const { provenance } = item;
131
+ return (
132
+ <div className="vi-studio-item-prov" aria-label="Provenance (immutable)">
133
+ <span className="vi-studio-item-prov-origin">{provenance.origin}</span>
134
+ <code className="vi-studio-item-prov-kind">{item.working.kind}</code>
135
+ {provenance.parentUri && (
136
+ <span className="vi-studio-item-prov-parent" title={provenance.parentUri}>
137
+ ← {shortUri(provenance.parentUri)}
138
+ </span>
139
+ )}
140
+ {provenance.model && (
141
+ <span
142
+ className="vi-studio-item-prov-model"
143
+ title={`model: ${provenance.model}`}
144
+ >
145
+ · {provenance.model}
146
+ </span>
147
+ )}
148
+ </div>
149
+ );
150
+ }
151
+
152
+ // Mount the matching Core in the drawer. Each arrow's `meta` is contextually typed
153
+ // from the Core's own onProduced prop, so the axes flow through with their exact
154
+ // shape; the crop cores run SINGLE-region (multiRegion={false}) inline-on-item.
155
+ function renderCore(op: OpKind, working: MediaRef, onProduced: ProducedSink): ReactNode {
156
+ switch (op) {
157
+ case "image_crop":
158
+ return (
159
+ <ImageCropCore
160
+ source={working}
161
+ multiRegion={false}
162
+ onProduced={(ref, meta) => onProduced(op, ref, meta)}
163
+ />
164
+ );
165
+ case "video_crop":
166
+ return (
167
+ <VideoCropCore
168
+ source={working}
169
+ onProduced={(ref, meta) => onProduced(op, ref, meta)}
170
+ />
171
+ );
172
+ case "audio_crop":
173
+ return (
174
+ <AudioCropCore
175
+ source={working}
176
+ multiRegion={false}
177
+ onProduced={(ref, meta) => onProduced(op, ref, meta)}
178
+ />
179
+ );
180
+ case "frame_extract":
181
+ return (
182
+ <FrameExtractCore
183
+ source={working}
184
+ busy={false}
185
+ addFile={null}
186
+ onProduced={(ref) => onProduced(op, ref)}
187
+ />
188
+ );
189
+ }
190
+ }
191
+
192
+ export interface StudioItemCardProps {
193
+ item: StudioItem;
194
+ /** same-kind op → parent working replaced (mutate in place). */
195
+ onApplyOp: (item: StudioItem, op: StudioItemOp) => void;
196
+ /** kind-change / fan-out → new child item(s) carrying parent provenance. */
197
+ onSpawn: (parent: StudioItem, produced: MediaRef[], kind: OpKind) => void;
198
+ onRevert: (item: StudioItem) => void;
199
+ onRemove: (item: StudioItem) => void;
200
+ }
201
+
202
+ export function StudioItemCard({
203
+ item,
204
+ onApplyOp,
205
+ onSpawn,
206
+ onRevert,
207
+ onRemove,
208
+ }: StudioItemCardProps) {
209
+ const [activeOp, setActiveOp] = useState<OpKind | null>(null);
210
+
211
+ // Per-card guard against the shared per-station tracker RE-EMITTING pre-existing
212
+ // done jobs. The crop cores fire onProduced from a useEffect over the tracker's
213
+ // rows for their station, so a freshly-mounted core (every drawer open) would
214
+ // re-notify EVERY historical done crop of that station — spawning phantom children
215
+ // or re-applying stale results. We remember each uri this card has already routed,
216
+ // and SEED that set at drawer-open with the station's current outputs, so only NEW
217
+ // completions route. (frame_extract's sink is pick-triggered, not job-triggered, so
218
+ // it is inherently immune; seeding it is harmless.)
219
+ const routedUris = useRef<Set<string>>(new Set());
220
+
221
+ const working = item.working;
222
+ const ops = opsFor(working.kind);
223
+ // Revert is meaningful only once an op has diverged `working` from the source.
224
+ const canRevert = working !== item.source;
225
+
226
+ function openOp(op: OpKind) {
227
+ const station = STATION_FOR_OP[op];
228
+ const seen = routedUris.current;
229
+ for (const rec of getJobs()) {
230
+ if (rec.station === station) {
231
+ for (const out of rec.outputs) seen.add(out.uri);
232
+ }
233
+ }
234
+ setActiveOp(op);
235
+ }
236
+
237
+ // The single onProduced sink the mounted core drives. Routes ONCE per uri.
238
+ function handleProduced(op: OpKind, ref: MediaRef, meta?: ProducedMeta) {
239
+ if (!ref || routedUris.current.has(ref.uri)) return;
240
+ routedUris.current.add(ref.uri);
241
+
242
+ // Light provenance enrichment: tie the produced library entry back to its parent
243
+ // item (groupId) and carry any inherited prompt/model. LibraryProvenance has no
244
+ // free "op kind" field (genKind is a closed generation union), so the op kind
245
+ // itself is recorded on the StudioItemOp / the spawned child's provenance instead.
246
+ updateLibraryProvenance(ref.uri, {
247
+ groupId: item.key,
248
+ ...(item.provenance.prompt != null ? { prompt: item.provenance.prompt } : {}),
249
+ ...(item.provenance.model != null ? { model: item.provenance.model } : {}),
250
+ });
251
+
252
+ const sameKind = op !== "frame_extract" && producedKind(op) === working.kind;
253
+ if (sameKind) {
254
+ onApplyOp(item, {
255
+ id: opKey(),
256
+ kind: op,
257
+ at: Date.now(),
258
+ spatial: meta?.spatial ?? null,
259
+ temporal: meta?.temporal ?? null,
260
+ result: ref,
261
+ });
262
+ } else {
263
+ onSpawn(item, [ref], op);
264
+ }
265
+ // The op resolved — close the drawer.
266
+ setActiveOp(null);
267
+ }
268
+
269
+ return (
270
+ <div className="vi-studio-item">
271
+ <div className="vi-studio-item-body">
272
+ <div className="vi-studio-item-media">{renderMedia(working)}</div>
273
+ <div className="vi-studio-item-meta">
274
+ <ProvenanceLine item={item} />
275
+ <div
276
+ className="vi-studio-item-ops"
277
+ role="group"
278
+ aria-label="Item operations"
279
+ >
280
+ {ops.map((op) => (
281
+ <button
282
+ key={op}
283
+ type="button"
284
+ className={
285
+ activeOp === op
286
+ ? "vi-btn vi-btn-sm vi-btn-accent"
287
+ : "vi-btn vi-btn-sm"
288
+ }
289
+ aria-pressed={activeOp === op}
290
+ onClick={() => (activeOp === op ? setActiveOp(null) : openOp(op))}
291
+ >
292
+ {OP_LABEL[op]}
293
+ </button>
294
+ ))}
295
+ {canRevert && (
296
+ <button
297
+ type="button"
298
+ className="vi-btn vi-btn-sm vi-btn-ghost"
299
+ onClick={() => onRevert(item)}
300
+ title="Restore the working ref back to the immutable source"
301
+ >
302
+ Revert to source
303
+ </button>
304
+ )}
305
+ <button
306
+ type="button"
307
+ className="vi-btn vi-btn-sm vi-btn-ghost"
308
+ onClick={() => onRemove(item)}
309
+ title="Remove this item from the studio (forgets the local card only)"
310
+ >
311
+ Remove
312
+ </button>
313
+ </div>
314
+ </div>
315
+ </div>
316
+
317
+ {activeOp && (
318
+ <div className="vi-studio-item-drawer">
319
+ <div className="vi-studio-item-drawer-head">
320
+ <span className="vi-studio-item-drawer-title">{OP_LABEL[activeOp]}</span>
321
+ <button
322
+ type="button"
323
+ className="vi-btn vi-btn-sm vi-btn-ghost"
324
+ onClick={() => setActiveOp(null)}
325
+ >
326
+ Close
327
+ </button>
328
+ </div>
329
+ {renderCore(activeOp, working, handleProduced)}
330
+ </div>
331
+ )}
332
+ </div>
333
+ );
334
+ }