@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,328 @@
1
+ // The media library — a session-scoped, cross-station store of produced MediaRefs.
2
+ //
3
+ // This is the FieldSpec-propagation analog for the video-intelligence arm: a
4
+ // station (Frames today) pushes the frames the user picks in here, and a later
5
+ // station (Generate) reads them back as selectable prompt inputs. Backed by
6
+ // sessionStorage (per-tab, keyed by session id) so a page reload keeps the picks
7
+ // but a new tab/session starts clean. `clearLibrary` empties the SESSION list
8
+ // only — it NEVER deletes the underlying server assets (house policy: no deletion).
9
+ //
10
+ // The public surface below is FROZEN — the Generate station imports it verbatim.
11
+ import { useSyncExternalStore } from "react";
12
+ import { getSessionId } from "../session";
13
+ import { isCanned } from "../demo/mode";
14
+ import type { MediaRef } from "./contract";
15
+
16
+ /** The generation kinds whose outputs carry prompt/model provenance. */
17
+ export type LibraryGenKind =
18
+ | "generate_image"
19
+ | "generate_scene"
20
+ | "generate_movie"
21
+ | "studio_i2v";
22
+
23
+ export interface LibraryItem {
24
+ /** The produced asset. */
25
+ ref: MediaRef;
26
+ /** Which station/flow produced it (e.g. "frames"). */
27
+ origin: string;
28
+ /** Optional human label (e.g. "frame 12"). */
29
+ label?: string;
30
+ /** When it landed in the library (ms epoch). */
31
+ addedAt: number;
32
+ /**
33
+ * Generation provenance — present ONLY for Generate-station outputs (image &
34
+ * scene). Captured client-side at generation time (the composer already holds
35
+ * the prompt + model), so the session-library can annotate each output and
36
+ * re-stage its prompt/model for Replicate / Continue. Only the server MediaRef
37
+ * (not the pixels) is persisted, per the library's storage-quota policy.
38
+ */
39
+ prompt?: string;
40
+ model?: string;
41
+ /** Groups a single generation run's outputs together (the producing job id). */
42
+ groupId?: string;
43
+ /** The generation kind of the producing run — enables Continue for scenes. */
44
+ genKind?: LibraryGenKind;
45
+ }
46
+
47
+ /** Optional generation provenance recorded alongside a produced MediaRef. */
48
+ export interface LibraryProvenance {
49
+ prompt?: string;
50
+ model?: string;
51
+ groupId?: string;
52
+ genKind?: LibraryGenKind;
53
+ }
54
+
55
+ /**
56
+ * The immutable "where did this come from" half of a first-class StudioItem (see
57
+ * stations/studio/studioItem.ts). Defined HERE — next to the library provenance
58
+ * types — rather than in studioItem.ts so the video/ layer (studioBridge's
59
+ * StudioStageRequest) can reference it WITHOUT importing from stations/, preserving
60
+ * the stations/ → video/ dependency direction. studioItem.ts re-exports it as the
61
+ * canonical StudioItem surface.
62
+ */
63
+ export interface StudioItemProvenance {
64
+ /** How this item entered the studio: "upload" | "library" | "send-to-studio" |
65
+ * "restyle" | "op:<kind>" | … */
66
+ origin: string;
67
+ prompt?: string;
68
+ model?: string;
69
+ genKind?: LibraryGenKind;
70
+ /** uri of the ref this item derived from — the forward-only provenance DAG edge. */
71
+ parentUri?: string;
72
+ }
73
+
74
+ const KEY_PREFIX = "vi.mediaLibrary.v1";
75
+
76
+ // Session-scoped key so two tabs (two sessions) keep independent libraries, and
77
+ // the persisted list travels with the same session id the uploads are tagged by.
78
+ //
79
+ // The canned demo (?demo=1) gets a `:demo`-SUFFIXED key (matching jobTracker) so
80
+ // the brochure's seeded library is hermetic — it never reads or writes the SAME
81
+ // tab's live-session library, and the tombstone key (derived below from
82
+ // storageKey) follows suit. isCanned() is URL-derived and available at module
83
+ // load, so the key is right from the first read regardless of import order.
84
+ function storageKey(): string {
85
+ const suffix = isCanned() ? ":demo" : "";
86
+ try {
87
+ return `${KEY_PREFIX}:${getSessionId()}${suffix}`;
88
+ } catch {
89
+ return `${KEY_PREFIX}${suffix}`;
90
+ }
91
+ }
92
+
93
+ // In-memory cache is the source of truth for getSnapshot — its reference only
94
+ // changes when the list actually changes, which keeps useSyncExternalStore stable
95
+ // (returning a fresh array every read would loop React 18).
96
+ let cache: LibraryItem[] | null = null;
97
+ const listeners = new Set<() => void>();
98
+ let storageBound = false;
99
+
100
+ function readStorage(): LibraryItem[] {
101
+ try {
102
+ const raw = sessionStorage.getItem(storageKey());
103
+ if (!raw) return [];
104
+ const parsed = JSON.parse(raw);
105
+ if (!Array.isArray(parsed)) return [];
106
+ // Drop malformed entries defensively — a stored item must at least carry a uri.
107
+ return parsed.filter(
108
+ (it) =>
109
+ it &&
110
+ typeof it === "object" &&
111
+ it.ref &&
112
+ typeof it.ref === "object" &&
113
+ typeof it.ref.uri === "string",
114
+ ) as LibraryItem[];
115
+ } catch {
116
+ return [];
117
+ }
118
+ }
119
+
120
+ function writeStorage(items: LibraryItem[]): void {
121
+ try {
122
+ sessionStorage.setItem(storageKey(), JSON.stringify(items));
123
+ } catch {
124
+ // storage unavailable / quota exceeded — the in-memory cache still serves this
125
+ // session; persistence is best-effort.
126
+ }
127
+ }
128
+
129
+ function ensureLoaded(): LibraryItem[] {
130
+ if (cache == null) cache = readStorage();
131
+ return cache;
132
+ }
133
+
134
+ function emit(): void {
135
+ for (const l of listeners) l();
136
+ }
137
+
138
+ // ── Tombstones ─────────────────────────────────────────────────────────────
139
+ // A "Remove" that comes back on the next poll isn't a remove. The list has TWO
140
+ // re-populators that fire AFTER a local forget: jobTracker's terminal pushOutputs
141
+ // (re-runs on a reload / a re-poll of a done job) and the studio-clips list poll
142
+ // (re-adds every ~6s). addToLibrary's only guard was uri-dedup, which a *removed*
143
+ // uri no longer trips — so the item silently returned. Tombstones fix this at the
144
+ // single choke point: a removed uri is remembered and addToLibrary refuses to
145
+ // re-add it. Session-scoped (same key family as the list) so removal survives
146
+ // reloads within the session; studio clips ALSO archive server-side in the row
147
+ // handler, which stops the server from serving them across sessions too.
148
+ function tombstoneKey(): string {
149
+ return `${storageKey()}:removed`;
150
+ }
151
+
152
+ let tombstones: Set<string> | null = null;
153
+
154
+ function ensureTombstones(): Set<string> {
155
+ if (tombstones == null) {
156
+ try {
157
+ const raw = sessionStorage.getItem(tombstoneKey());
158
+ const parsed = raw ? JSON.parse(raw) : [];
159
+ tombstones = new Set(
160
+ Array.isArray(parsed) ? parsed.filter((u) => typeof u === "string") : [],
161
+ );
162
+ } catch {
163
+ tombstones = new Set();
164
+ }
165
+ }
166
+ return tombstones;
167
+ }
168
+
169
+ function writeTombstones(): void {
170
+ try {
171
+ sessionStorage.setItem(tombstoneKey(), JSON.stringify([...ensureTombstones()]));
172
+ } catch {
173
+ // best-effort — the in-memory set still suppresses re-adds this session.
174
+ }
175
+ }
176
+
177
+ /** Mark `uri` as removed so no re-populator can re-add it. */
178
+ function tombstone(uri: string): void {
179
+ ensureTombstones().add(uri);
180
+ writeTombstones();
181
+ }
182
+
183
+ /** Whether `uri` was explicitly removed (and must not be re-added). */
184
+ export function isRemoved(uri: string): boolean {
185
+ return ensureTombstones().has(uri);
186
+ }
187
+
188
+ /** The current library snapshot (stable reference until it changes). */
189
+ export function getLibrary(): LibraryItem[] {
190
+ return ensureLoaded();
191
+ }
192
+
193
+ /**
194
+ * Add a produced MediaRef to the library. De-duplicates by `ref.uri` (no-op if
195
+ * present). The optional `provenance` argument is additive (existing 3-arg
196
+ * callers are unchanged) — it records the prompt/model/group that produced a
197
+ * Generate-station output so the session-library can annotate + re-stage it.
198
+ */
199
+ export function addToLibrary(
200
+ ref: MediaRef,
201
+ origin: string,
202
+ label?: string,
203
+ provenance?: LibraryProvenance,
204
+ ): void {
205
+ if (!ref || typeof ref.uri !== "string") return;
206
+ // A uri the user explicitly removed stays gone — never let a re-populator
207
+ // (jobTracker terminal push / studio-clips poll) resurrect it. See tombstones.
208
+ if (isRemoved(ref.uri)) return;
209
+ const current = ensureLoaded();
210
+ if (current.some((it) => it.ref.uri === ref.uri)) return;
211
+ const item: LibraryItem = { ref, origin, addedAt: Date.now() };
212
+ if (label != null) item.label = label;
213
+ if (provenance?.prompt != null) item.prompt = provenance.prompt;
214
+ if (provenance?.model != null) item.model = provenance.model;
215
+ if (provenance?.groupId != null) item.groupId = provenance.groupId;
216
+ if (provenance?.genKind != null) item.genKind = provenance.genKind;
217
+ cache = [...current, item];
218
+ writeStorage(cache);
219
+ emit();
220
+ }
221
+
222
+ /**
223
+ * Merge generation provenance into an EXISTING library item (matched by `ref.uri`).
224
+ * Additive back-fill for provenance discovered AFTER the ref was first added: the
225
+ * studio clip catalog adds each produced clip on its list poll (keyed by uri, but the
226
+ * clips-list DTO carries no prompt), then fills the REAL prompt/model in from the
227
+ * per-clip detail fetch — this is that seam. Only writes a field the item does not
228
+ * already carry (never clobbers a value captured at generation time), and no-ops if
229
+ * the uri is absent or nothing would change. Never touches server assets.
230
+ */
231
+ export function updateLibraryProvenance(
232
+ uri: string,
233
+ provenance: LibraryProvenance,
234
+ ): void {
235
+ if (typeof uri !== "string" || !provenance) return;
236
+ const current = ensureLoaded();
237
+ const idx = current.findIndex((it) => it.ref.uri === uri);
238
+ if (idx < 0) return;
239
+ const item = current[idx];
240
+ const next: LibraryItem = { ...item };
241
+ let changed = false;
242
+ if (provenance.prompt != null && item.prompt == null) {
243
+ next.prompt = provenance.prompt;
244
+ changed = true;
245
+ }
246
+ if (provenance.model != null && item.model == null) {
247
+ next.model = provenance.model;
248
+ changed = true;
249
+ }
250
+ if (provenance.groupId != null && item.groupId == null) {
251
+ next.groupId = provenance.groupId;
252
+ changed = true;
253
+ }
254
+ if (provenance.genKind != null && item.genKind == null) {
255
+ next.genKind = provenance.genKind;
256
+ changed = true;
257
+ }
258
+ if (!changed) return;
259
+ cache = [...current.slice(0, idx), next, ...current.slice(idx + 1)];
260
+ writeStorage(cache);
261
+ emit();
262
+ }
263
+
264
+ /**
265
+ * Remove a SINGLE library entry by its `ref.uri`, PERMANENTLY for the session:
266
+ * the uri is tombstoned so no re-populator (jobTracker terminal push / studio-clips
267
+ * poll) can ever bring it back — a removal that repopulates isn't a removal. Does
268
+ * NOT itself delete the underlying server asset (house policy: no deletion; studio
269
+ * clips archive server-side in the row handler). Tombstone is recorded even when
270
+ * the row is momentarily absent, so a remove that races a re-add still sticks.
271
+ */
272
+ export function removeFromLibrary(uri: string): void {
273
+ if (typeof uri !== "string") return;
274
+ tombstone(uri); // record the removal FIRST so a concurrent re-add can't win.
275
+ const current = ensureLoaded();
276
+ const next = current.filter((it) => it.ref.uri !== uri);
277
+ if (next.length === current.length) {
278
+ emit(); // not in the list, but the tombstone changed — let subscribers re-render.
279
+ return;
280
+ }
281
+ cache = next;
282
+ writeStorage(cache);
283
+ emit();
284
+ }
285
+
286
+ /**
287
+ * Empty the SESSION library list. Tombstones every current uri so the studio-clips
288
+ * poll (and any terminal re-push) can't repopulate what was just cleared — "Clear
289
+ * all" that refills on the next tick isn't clearing. Does NOT delete server assets
290
+ * (house policy: no deletion).
291
+ */
292
+ export function clearLibrary(): void {
293
+ for (const it of ensureLoaded()) tombstone(it.ref.uri);
294
+ cache = [];
295
+ try {
296
+ sessionStorage.removeItem(storageKey());
297
+ } catch {
298
+ // ignore — cache is already cleared for this session.
299
+ }
300
+ emit();
301
+ }
302
+
303
+ // Bind the cross-tab storage listener once. sessionStorage is per-tab so this
304
+ // mainly future-proofs the surface, but if the key ever changes underneath us we
305
+ // re-sync the cache and notify rather than serving a stale list.
306
+ function bindStorageOnce(): void {
307
+ if (storageBound || typeof window === "undefined") return;
308
+ storageBound = true;
309
+ window.addEventListener("storage", (e) => {
310
+ if (e.key !== null && e.key !== storageKey()) return;
311
+ cache = readStorage();
312
+ emit();
313
+ });
314
+ }
315
+
316
+ /** Subscribe to add/clear (and cross-tab storage) changes. Returns an unsubscribe. */
317
+ export function subscribeLibrary(listener: () => void): () => void {
318
+ listeners.add(listener);
319
+ bindStorageOnce();
320
+ return () => {
321
+ listeners.delete(listener);
322
+ };
323
+ }
324
+
325
+ /** React hook: the live library list, re-rendering on every add/clear. */
326
+ export function useMediaLibrary(): LibraryItem[] {
327
+ return useSyncExternalStore(subscribeLibrary, getLibrary, getLibrary);
328
+ }