@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,339 @@
1
+ // The player-forward VIEWER — the .vi-studio-viewer stage (mirrors the scene/video tab
2
+ // preview) that plays the selected clip. It lives at the TOP of the studio plane
3
+ // (StudioPlane), driven by the plane's lifted `selected` + `pinned`.
4
+ //
5
+ // Header-bar controls (operator ask 2026-07-12 — "Can the change be made THROUGH to
6
+ // change the initial scene or video or make it 'new'."). Before this pass the viewer had
7
+ // NO controls of its own: it silently rode the plane's auto-follow effect and, on top of
8
+ // that, silently substituted "any playable clip" whenever `selected` didn't resolve to
9
+ // one — including a DELIBERATELY cleared selection. Combined with an auto-follow effect
10
+ // that (see StudioPlane.tsx) only re-picked when the selected job vanished from the list
11
+ // entirely, the viewer would latch onto the first playable clip of the session and then
12
+ // never visibly change again — exactly the "stuck, unchangeable default" the operator
13
+ // described. Fixed on both ends:
14
+ // • `chosen` below is now a STRICT read of `selected` — no silent substitution. A
15
+ // cleared viewer (`selected === null`) shows the explicit empty state and only the
16
+ // explicit empty state, until the operator picks something or resumes follow.
17
+ // • the plane's auto-follow effect now genuinely re-syncs to the newest playable clip
18
+ // on every list change (see that file's comment) instead of sticking forever.
19
+ // `pinned` is the one semantic switch this header reads and toggles: true once the
20
+ // operator has manually picked or cleared, false while the plane auto-follows.
21
+ //
22
+ // Layout below is INLINE style (not new app.css classes) — this slice is scoped to
23
+ // src/stations/studio/ only, and the file-local ad hoc `style={{...}}` layout is
24
+ // already the established idiom for one-off arrangement in this same directory (see
25
+ // StudioGenerateTab.tsx's picker rows / hero sections). Reused, not-new pieces (the
26
+ // button classes, the .vi-studio-src-row poster/id row, .vi-comfy-empty) still use
27
+ // their existing classes.
28
+ import { useCallback, useEffect, useState } from "react";
29
+ import {
30
+ studioClipUrl,
31
+ studioClipToEditorUrl,
32
+ hugpyConfig,
33
+ } from "../../config";
34
+ import { request, okValue, errorOf, describeAppError } from "../../transport/client";
35
+ import { shortId, dims, whenText, type Clip } from "./studioShared";
36
+ import { SourceThumb } from "./StudioGenerateTab";
37
+
38
+ export interface StudioViewerProps {
39
+ clips: Clip[];
40
+ /** The plane's lifted selection — a job id, or null (nothing chosen / explicitly
41
+ * cleared). Read STRICTLY below; there is no "nearest playable" fallback any more. */
42
+ selected: string | null;
43
+ /** True once the operator has taken manual control (a library pick, or "Clear
44
+ * viewer") — the plane's auto-follow effect stands down while this is true. Drives
45
+ * the "↻ follow newest" toggle's shown state. */
46
+ pinned: boolean;
47
+ /** "▤ Play from library" panel pick — hands the chosen job id back to the plane,
48
+ * which selects it AND pins (see StudioPlane.tsx). */
49
+ onPick: (jobId: string) => void;
50
+ /** "✕ Clear viewer" — empties the selection; the plane pins alongside it so the empty
51
+ * state sticks instead of being repopulated by the next poll. */
52
+ onClear: () => void;
53
+ /** "↻ follow newest" toggle — flips `pinned` on the plane. */
54
+ onToggleFollow: () => void;
55
+ }
56
+
57
+ export function StudioViewer({
58
+ clips,
59
+ selected,
60
+ pinned,
61
+ onPick,
62
+ onClear,
63
+ onToggleFollow,
64
+ }: StudioViewerProps) {
65
+ // Local to the picker panel only — never touches the plane's lifted selection.
66
+ const [pickerOpen, setPickerOpen] = useState(false);
67
+ const [previewId, setPreviewId] = useState<string | null>(null);
68
+
69
+ // STRICT: only what `selected` names, and only while it is actually playable. No "any
70
+ // playable clip" fallback — that silent substitution is exactly what made a cleared or
71
+ // stuck-on-a-failed-job viewer unable to ever show empty (see the file header note).
72
+ const chosen = selected
73
+ ? (clips.find((c) => c.job_id === selected && c.playable) ?? null)
74
+ : null;
75
+
76
+ const playable = clips.filter((c) => c.playable);
77
+
78
+ // ── "Send to Editor" (k12) — CONSOLE-OPERATOR ONLY affordance ───────────────
79
+ // Visibility is AUTH-DECIDED, not guessed (mirrors SharePanel): probe the same
80
+ // operator-gated GET (keysVideoShareUrl) once on mount. null = probing (hide),
81
+ // false = a share guest / anon (hide — the route would 403 anyway), true =
82
+ // operator (show, but only alongside a real playable clip). A second small GET
83
+ // here is consistent with SharePanel's own probe, not a regression.
84
+ const [operator, setOperator] = useState<boolean | null>(null);
85
+ const [sending, setSending] = useState(false);
86
+ const [sentMsg, setSentMsg] = useState<string | null>(null);
87
+ const [sendErr, setSendErr] = useState<string | null>(null);
88
+
89
+ useEffect(() => {
90
+ let alive = true;
91
+ void (async () => {
92
+ const res = await request<unknown>(hugpyConfig.keysVideoShareUrl, {
93
+ method: "GET",
94
+ meta: { specKey: "studio", operation: "studio.operatorProbe" },
95
+ });
96
+ if (alive) setOperator(res.ok);
97
+ })();
98
+ return () => {
99
+ alive = false;
100
+ };
101
+ }, []);
102
+
103
+ // Clear any transient send message when the shown clip changes, so a "sent"
104
+ // note never lingers over a different clip.
105
+ useEffect(() => {
106
+ setSentMsg(null);
107
+ setSendErr(null);
108
+ }, [chosen?.job_id]);
109
+
110
+ const sendToEditor = useCallback(async () => {
111
+ if (!chosen) return;
112
+ setSending(true);
113
+ setSentMsg(null);
114
+ setSendErr(null);
115
+ const res = await request<{ filename?: string; mode?: string }>(
116
+ studioClipToEditorUrl(chosen.job_id),
117
+ { method: "POST", meta: { specKey: "studio", operation: "studio.clip.toEditor" } },
118
+ );
119
+ setSending(false);
120
+ if (!res.ok) {
121
+ setSendErr(`Could not send to the editor — ${describeAppError(errorOf(res))}`);
122
+ return;
123
+ }
124
+ const fn = okValue(res).filename ?? "clip.mp4";
125
+ const msg = `✓ Sent to editor — ${fn}`;
126
+ setSentMsg(msg);
127
+ window.setTimeout(() => setSentMsg((cur) => (cur === msg ? null : cur)), 4000);
128
+ }, [chosen]);
129
+
130
+ function pick(jobId: string) {
131
+ onPick(jobId);
132
+ setPickerOpen(false);
133
+ setPreviewId(null);
134
+ }
135
+
136
+ return (
137
+ <section aria-label="Viewer" className="vi-studio-viewer-wrap">
138
+ {/* ── ALWAYS-VISIBLE header bar: the viewer must never read as a fixed, unmovable
139
+ default. It always names what's showing (or says nothing is), and always offers
140
+ a way to pick something else, clear it, or hand control back to auto-follow. ── */}
141
+ <div
142
+ style={{
143
+ display: "flex",
144
+ flexWrap: "wrap",
145
+ alignItems: "center",
146
+ justifyContent: "space-between",
147
+ gap: "0.6rem",
148
+ marginBottom: "0.5rem",
149
+ }}
150
+ >
151
+ <span
152
+ style={{
153
+ fontFamily: "var(--vi-mono)",
154
+ fontSize: "0.85rem",
155
+ color: "var(--vi-text)",
156
+ overflow: "hidden",
157
+ textOverflow: "ellipsis",
158
+ whiteSpace: "nowrap",
159
+ }}
160
+ title={chosen ? chosen.job_id : "Nothing playing"}
161
+ >
162
+ {chosen ? `▶ ${shortId(chosen.job_id)}` : "— nothing playing —"}
163
+ </span>
164
+ <div
165
+ style={{ display: "flex", flexWrap: "wrap", gap: "0.35rem" }}
166
+ role="group"
167
+ aria-label="Viewer controls"
168
+ >
169
+ {/* CONSOLE-OPERATOR ONLY (operator === true) AND only with a real playable
170
+ clip showing: hand this clip to the LAN Filmora inbox. Hidden for a
171
+ share guest / anon (the route would 403 regardless). */}
172
+ {operator === true && chosen && (
173
+ <button
174
+ type="button"
175
+ className="vi-btn vi-btn-sm vi-btn-accent"
176
+ disabled={sending}
177
+ onClick={() => void sendToEditor()}
178
+ title="Copy this clip, in a Filmora-ready MP4, into your editor inbox on the LAN"
179
+ >
180
+ {sending ? "Sending…" : "⇱ Send to Editor"}
181
+ </button>
182
+ )}
183
+ <button
184
+ type="button"
185
+ className="vi-btn vi-btn-sm vi-btn-ghost"
186
+ aria-expanded={pickerOpen}
187
+ onClick={() => setPickerOpen((v) => !v)}
188
+ title="Pick any playable clip to show in the viewer"
189
+ >
190
+ ▤ Play from library
191
+ </button>
192
+ <button
193
+ type="button"
194
+ className="vi-btn vi-btn-sm vi-btn-ghost"
195
+ onClick={onClear}
196
+ disabled={!chosen && pinned}
197
+ title="Empty the viewer — shows an explicit empty state instead of a clip"
198
+ >
199
+ ✕ Clear viewer
200
+ </button>
201
+ <button
202
+ type="button"
203
+ className={pinned ? "vi-btn vi-btn-sm vi-btn-ghost" : "vi-btn vi-btn-sm vi-btn-accent"}
204
+ aria-pressed={!pinned}
205
+ onClick={onToggleFollow}
206
+ title={
207
+ pinned
208
+ ? "Off — the viewer stays on your pick. Click to resume following the newest playable clip."
209
+ : "On — the viewer follows the newest playable clip as renders complete. Click to freeze it here."
210
+ }
211
+ >
212
+ ↻ follow newest · {pinned ? "off" : "on"}
213
+ </button>
214
+ </div>
215
+ </div>
216
+
217
+ {/* Send-to-Editor transient feedback (operator only). Success auto-clears
218
+ after a few seconds; a failure stays until the next send or clip change. */}
219
+ {sentMsg && (
220
+ <p
221
+ className="vi-comfy-hint"
222
+ role="status"
223
+ style={{ margin: "0 0 0.5rem", color: "var(--vi-accent)" }}
224
+ >
225
+ {sentMsg}
226
+ </p>
227
+ )}
228
+ {sendErr && (
229
+ <p className="vi-error" role="alert" style={{ margin: "0 0 0.5rem" }}>
230
+ {sendErr}
231
+ </p>
232
+ )}
233
+
234
+ {/* "▤ Play from library" panel — the reachable select-to-viewer affordance. (The
235
+ original StudioLibrarySection had this exact click-to-select logic, but that
236
+ component is unmounted in BOTH plane mounts — see StudioLibraryTab.tsx and the
237
+ StudioPlane.tsx header note. This panel is the live replacement: mounted once,
238
+ inside the ONE shared StudioPlane, so it is reachable identically from the
239
+ standalone Studio station AND the Generate station's studio mode.) Rows reuse
240
+ the studio "source clip" poster/id idiom (SourceThumb + .vi-studio-src-row,
241
+ exported from StudioGenerateTab.tsx) rather than inventing a fifth row shape. */}
242
+ {pickerOpen && (
243
+ <div
244
+ style={{
245
+ marginBottom: "0.6rem",
246
+ padding: "0.6rem",
247
+ border: "1px solid var(--vi-border)",
248
+ borderRadius: "8px",
249
+ background: "var(--vi-surface-raised)",
250
+ }}
251
+ role="listbox"
252
+ aria-label="Play from library"
253
+ >
254
+ {playable.length === 0 ? (
255
+ <p className="vi-comfy-hint">No playable clips yet — generate one below.</p>
256
+ ) : (
257
+ <ul
258
+ style={{
259
+ listStyle: "none",
260
+ margin: 0,
261
+ padding: 0,
262
+ maxHeight: "16rem",
263
+ overflowY: "auto",
264
+ }}
265
+ >
266
+ {playable.map((c) => {
267
+ const uri = c.output?.uri;
268
+ const isPreviewing = previewId === c.job_id;
269
+ const isCurrent = c.job_id === selected;
270
+ return (
271
+ <li key={c.job_id} className="vi-studio-src-row">
272
+ {uri ? (
273
+ <SourceThumb
274
+ uri={uri}
275
+ name={shortId(c.job_id)}
276
+ playing={isPreviewing}
277
+ onToggle={() =>
278
+ setPreviewId((cur) => (cur === c.job_id ? null : c.job_id))
279
+ }
280
+ />
281
+ ) : (
282
+ <div className="vi-studio-src-thumb vi-lib-placeholder" aria-hidden>
283
+ <span>clip</span>
284
+ </div>
285
+ )}
286
+ <div className="vi-studio-src-body">
287
+ <div className="vi-studio-src-meta" title={c.job_id}>
288
+ <code>{shortId(c.job_id)}</code>
289
+ {dims(c) ? <span>{dims(c)}</span> : null}
290
+ {whenText(c) ? <span>{whenText(c)}</span> : null}
291
+ {isCurrent ? <span>· current</span> : null}
292
+ </div>
293
+ <div className="vi-studio-src-actions">
294
+ <button
295
+ type="button"
296
+ className="vi-btn vi-btn-sm vi-btn-accent"
297
+ onClick={() => pick(c.job_id)}
298
+ disabled={isCurrent}
299
+ title="Play this clip in the viewer"
300
+ >
301
+ {isCurrent ? "▸ Playing" : "▸ Play here"}
302
+ </button>
303
+ </div>
304
+ </div>
305
+ </li>
306
+ );
307
+ })}
308
+ </ul>
309
+ )}
310
+ </div>
311
+ )}
312
+
313
+ {chosen ? (
314
+ <div className="vi-studio-viewer">
315
+ <video
316
+ key={chosen.job_id}
317
+ className="vi-studio-clip-video"
318
+ controls
319
+ autoPlay
320
+ loop
321
+ playsInline
322
+ src={studioClipUrl(chosen.job_id)}
323
+ />
324
+ </div>
325
+ ) : (
326
+ <div className="vi-comfy-empty">
327
+ <p className="vi-comfy-empty-title">
328
+ {pinned ? "Nothing playing — cleared" : "No clip yet"}
329
+ </p>
330
+ <p className="vi-comfy-empty-sub">
331
+ {pinned
332
+ ? "Pick from the library above, or turn “follow newest” back on."
333
+ : "Generate a clip below, or pick a playable clip from the library above — it plays here."}
334
+ </p>
335
+ </div>
336
+ )}
337
+ </section>
338
+ );
339
+ }
@@ -0,0 +1,220 @@
1
+ // TURNTABLE VIEWER — the drag-to-rotate half of the identity 360° orbit turntable.
2
+ //
3
+ // A turntable reconstruction stores ONE orbit clip's frames in its `views[]` array
4
+ // IN ANGULAR ORDER (frame 0 = start angle … frame N ≈ 360°). This component shows
5
+ // exactly ONE frame at a time and scrubs across the ordered frames as the user drags
6
+ // horizontally (pointer events) or moves the range slider — dragging/sliding left↔
7
+ // right maps position across [0..N-1] = rotating the character 0..360°. Every frame
8
+ // is a degree view, so scrubbing IS the rotation.
9
+ //
10
+ // Frames are PRELOADED (a `new Image()` per uri) on mount/whenever the frame set
11
+ // changes, so once warm the on-screen <img> src swap is instant (served from cache)
12
+ // and scrubbing is smooth. Served bytes come through `mediaBytesUrl(uri)`, the same
13
+ // media-bytes seam every other identity thumb uses.
14
+ //
15
+ // Presentational + self-contained: it owns only the current-frame index and drag
16
+ // bookkeeping; the caller (IdentitiesStation) gates rendering this vs the static
17
+ // sheet grid on the reconstruction's `mode`, and keeps its own approve-to-canonical
18
+ // control alongside.
19
+ import { useEffect, useMemo, useRef, useState } from "react";
20
+ import type { PointerEvent as ReactPointerEvent } from "react";
21
+ import { mediaBytesUrl } from "../../config";
22
+
23
+ function clampIndex(i: number, n: number): number {
24
+ if (n <= 0) return 0;
25
+ if (i < 0) return 0;
26
+ if (i > n - 1) return n - 1;
27
+ return i;
28
+ }
29
+
30
+ export function TurntableViewer({
31
+ frames,
32
+ degreesPerFrame,
33
+ frameCount,
34
+ }: {
35
+ /** The reconstruction's `views[]` — the orbit clip's frame uris, in angular order. */
36
+ frames: string[];
37
+ /** Optional manifest hint: degrees advanced per frame (else derived from count). */
38
+ degreesPerFrame?: number | null;
39
+ /** Optional manifest hint: authoritative frame count (else `frames.length`). */
40
+ frameCount?: number | null;
41
+ }) {
42
+ const n = frames.length;
43
+ // `frame_count` is a manifest convenience; the frames we can actually SHOW are the
44
+ // uris we were handed, so scrubbing is always bounded by `frames.length`.
45
+ const count = frameCount && frameCount > 0 ? frameCount : n;
46
+ const dpf = useMemo(() => {
47
+ if (degreesPerFrame && degreesPerFrame > 0) return degreesPerFrame;
48
+ return count > 0 ? 360 / count : 0;
49
+ }, [degreesPerFrame, count]);
50
+
51
+ const [index, setIndex] = useState(0);
52
+ const [loaded, setLoaded] = useState(0);
53
+ const stageRef = useRef<HTMLDivElement | null>(null);
54
+ const dragRef = useRef<{ startX: number; startIndex: number } | null>(null);
55
+
56
+ // Keep the index valid if the frame set changes out from under us (e.g. a fresh
57
+ // turntable replaces an older one on this same card).
58
+ useEffect(() => {
59
+ setIndex((i) => clampIndex(i, n));
60
+ }, [n]);
61
+
62
+ // PRELOAD every frame. Hold the Image objects in a ref for the effect's lifetime so
63
+ // the browser keeps them warm (and doesn't GC an in-flight decode); count decoded
64
+ // frames for a small "loading" readout while the orbit warms up.
65
+ useEffect(() => {
66
+ setLoaded(0);
67
+ if (n === 0) return;
68
+ let live = true;
69
+ const imgs: HTMLImageElement[] = [];
70
+ frames.forEach((uri) => {
71
+ const img = new Image();
72
+ img.onload = () => {
73
+ if (live) setLoaded((c) => c + 1);
74
+ };
75
+ img.onerror = () => {
76
+ // A broken frame still "settles" so the readout can reach N and not hang.
77
+ if (live) setLoaded((c) => c + 1);
78
+ };
79
+ img.src = mediaBytesUrl(uri);
80
+ imgs.push(img);
81
+ });
82
+ return () => {
83
+ live = false;
84
+ imgs.forEach((img) => {
85
+ img.onload = null;
86
+ img.onerror = null;
87
+ });
88
+ };
89
+ }, [frames, n]);
90
+
91
+ if (n === 0) return null;
92
+
93
+ // Map a horizontal drag across the FULL stage width to the FULL frame range: a drag
94
+ // spanning the whole viewer = a full 0..360° turn. Dragging RIGHT advances frames
95
+ // (the character rotates), dragging LEFT rewinds.
96
+ function onPointerDown(e: ReactPointerEvent<HTMLDivElement>) {
97
+ (e.currentTarget as HTMLDivElement).setPointerCapture?.(e.pointerId);
98
+ dragRef.current = { startX: e.clientX, startIndex: index };
99
+ }
100
+ function onPointerMove(e: ReactPointerEvent<HTMLDivElement>) {
101
+ const drag = dragRef.current;
102
+ if (!drag) return;
103
+ const width = stageRef.current?.clientWidth || 1;
104
+ const delta = e.clientX - drag.startX;
105
+ const framesMoved = Math.round((delta / width) * (count - 1 || 1));
106
+ setIndex(clampIndex(drag.startIndex + framesMoved, n));
107
+ }
108
+ function endDrag(e: ReactPointerEvent<HTMLDivElement>) {
109
+ (e.currentTarget as HTMLDivElement).releasePointerCapture?.(e.pointerId);
110
+ dragRef.current = null;
111
+ }
112
+
113
+ const angle = Math.round(index * dpf) % 360;
114
+ const warming = loaded < n;
115
+
116
+ return (
117
+ <div style={{ marginTop: "0.4rem" }}>
118
+ <p className="vi-comfy-label" style={{ margin: "0 0 0.3rem" }}>
119
+ 360° turntable
120
+ </p>
121
+ <div
122
+ ref={stageRef}
123
+ className="vi-turntable-stage"
124
+ role="slider"
125
+ aria-label="Drag to rotate the character"
126
+ aria-valuemin={0}
127
+ aria-valuemax={n - 1}
128
+ aria-valuenow={index}
129
+ aria-valuetext={`${angle}°`}
130
+ tabIndex={0}
131
+ onPointerDown={onPointerDown}
132
+ onPointerMove={onPointerMove}
133
+ onPointerUp={endDrag}
134
+ onPointerCancel={endDrag}
135
+ onKeyDown={(e) => {
136
+ if (e.key === "ArrowRight") setIndex((i) => clampIndex(i + 1, n));
137
+ else if (e.key === "ArrowLeft") setIndex((i) => clampIndex(i - 1, n));
138
+ }}
139
+ style={{
140
+ position: "relative",
141
+ width: "100%",
142
+ maxWidth: "18rem",
143
+ aspectRatio: "1 / 1",
144
+ borderRadius: "0.5rem",
145
+ overflow: "hidden",
146
+ background: "#000",
147
+ border: "1px solid var(--vi-border)",
148
+ touchAction: "none",
149
+ userSelect: "none",
150
+ }}
151
+ >
152
+ {frames.map((uri, i) => (
153
+ // Render ALL frames stacked, toggling visibility, so the already-decoded
154
+ // frame is simply revealed on scrub (no src swap / reflow flicker at all).
155
+ <img
156
+ key={uri}
157
+ src={mediaBytesUrl(uri)}
158
+ alt={i === index ? `orbit frame ${i + 1} of ${n} (${angle}°)` : ""}
159
+ draggable={false}
160
+ style={{
161
+ position: "absolute",
162
+ inset: 0,
163
+ width: "100%",
164
+ height: "100%",
165
+ objectFit: "cover",
166
+ opacity: i === index ? 1 : 0,
167
+ pointerEvents: "none",
168
+ }}
169
+ />
170
+ ))}
171
+ <span
172
+ className="vi-comfy-hint"
173
+ style={{
174
+ position: "absolute",
175
+ top: 4,
176
+ right: 6,
177
+ fontSize: "0.7rem",
178
+ padding: "0 0.3rem",
179
+ borderRadius: "0.3rem",
180
+ background: "rgba(0,0,0,0.55)",
181
+ textShadow: "0 0 3px #000",
182
+ }}
183
+ >
184
+ {angle}°
185
+ </span>
186
+ {warming && (
187
+ <span
188
+ className="vi-comfy-hint"
189
+ style={{
190
+ position: "absolute",
191
+ left: 6,
192
+ bottom: 4,
193
+ fontSize: "0.7rem",
194
+ padding: "0 0.3rem",
195
+ borderRadius: "0.3rem",
196
+ background: "rgba(0,0,0,0.55)",
197
+ opacity: 0.9,
198
+ }}
199
+ >
200
+ preloading {loaded}/{n}…
201
+ </span>
202
+ )}
203
+ </div>
204
+ <input
205
+ type="range"
206
+ className="vi-turntable-scrub"
207
+ min={0}
208
+ max={n - 1}
209
+ step={1}
210
+ value={index}
211
+ aria-label="Rotate the character"
212
+ onChange={(e) => setIndex(clampIndex(Number(e.target.value), n))}
213
+ style={{ width: "100%", maxWidth: "18rem", marginTop: "0.4rem", display: "block", cursor: "ew-resize" }}
214
+ />
215
+ <p className="vi-comfy-hint" style={{ margin: "0.25rem 0 0", opacity: 0.8 }}>
216
+ {n} frame{n === 1 ? "" : "s"} · ~{dpf ? dpf.toFixed(dpf < 10 ? 1 : 0) : "?"}°/frame · drag or scrub to rotate
217
+ </p>
218
+ </div>
219
+ );
220
+ }