@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,476 @@
1
+ // The VISUAL SPLICED ROW + branch-frame scrubber (slice B3) — the picture half of
2
+ // the operator's ask: "in the video edit row it displays a spliced video that can
3
+ // easily be conjoined at those points … the splice being the parent video |
4
+ // divergence, and the user's options decide."
5
+ //
6
+ // A studio movie is ONE NLE timeline row: `[segment 0 | segment 1 | …]`. This draws
7
+ // that row from the terminal manifest — each block sized to its EFFECTIVE frames in
8
+ // the assembly (a parent trimmed at its child's splice; the leaf full), with a
9
+ // scissors NOTCH at every join labelled with the parent frame the child diverged
10
+ // at. Clicking a rendered block opens a frame scrubber over THAT segment's clip; its
11
+ // one primary action appends a new branch goal to the composer's timeline.
12
+ //
13
+ // V1 BOUNDARIES (stated plainly; carried in the composer header too):
14
+ // • The row reads the LAST render. Branching edits the AUTHORED goal timeline and
15
+ // the user re-Generates — a studio job is IMMUTABLE, so extending a done movie
16
+ // re-enqueues a NEW job (it never mutates the old one).
17
+ // • v0 authors a LINEAR chain. The schema's take-tree (sibling divergence from one
18
+ // parent) is planned growth; until then branching from a NON-final block cannot
19
+ // keep the old tail as a sibling, so it TRUNCATES (see the composer's onBranch +
20
+ // the destructive-note wiring below).
21
+ //
22
+ // Rendered ONCE by the composer (never inside a .map) so no `key` ever lands on this
23
+ // custom component — the repo's JSX typing rejects that (see StudioGenerateTab). All
24
+ // internal lists key on intrinsic elements.
25
+ import { useEffect, useRef, useState } from "react";
26
+ import { mediaBytesUrl } from "../../config";
27
+ import type { MediaRef } from "../../video/contract";
28
+ import {
29
+ deriveRow,
30
+ resolveFps,
31
+ clampFrame,
32
+ frameToTime,
33
+ timeToFrame,
34
+ type RowSegment,
35
+ type RowJoint,
36
+ } from "./movieTimeline";
37
+
38
+ export interface StudioSplicedRowProps {
39
+ /** Terminal manifest segments (index-aligned with `segmentRefs`). */
40
+ segments: RowSegment[];
41
+ /** Terminal manifest joints (per-splice trim ledger). */
42
+ joints: RowJoint[];
43
+ /** Per-segment clip MediaRefs, in order (outputs sans the trailing movie.mp4). */
44
+ segmentRefs: MediaRef[];
45
+ /** Movie fps from the manifest (falls back to the composer knob). */
46
+ fps: number;
47
+ fpsFallback: number;
48
+ /** Authored goal-row count RIGHT NOW — clean append (last) vs truncate (mid-row). */
49
+ goalCount: number;
50
+ /** Append a branch goal after `segmentIndex`, conditioned on `frame` of its clip. */
51
+ onBranch: (segmentIndex: number, frame: number) => void;
52
+ }
53
+
54
+ const BLOCK_MIN_H = "3.4rem";
55
+
56
+ export function StudioSplicedRow({
57
+ segments,
58
+ joints,
59
+ segmentRefs,
60
+ fps,
61
+ fpsFallback,
62
+ goalCount,
63
+ onBranch,
64
+ }: StudioSplicedRowProps) {
65
+ const effFps = resolveFps(fps, fpsFallback);
66
+ const { blocks, splices } = deriveRow(segments, joints);
67
+ const spliceAfter = new Map<number, (typeof splices)[number]>();
68
+ splices.forEach((s) => spliceAfter.set(s.afterIndex, s));
69
+
70
+ // Which block's scrubber is open (by segment index), and its live frame cursor.
71
+ const [openIndex, setOpenIndex] = useState<number | null>(null);
72
+ const [frame, setFrame] = useState(0);
73
+ const videoRef = useRef<HTMLVideoElement | null>(null);
74
+
75
+ const openBlock = blocks.find((b) => b.index === openIndex) ?? null;
76
+ const openRef = openIndex != null ? segmentRefs[openIndex] : undefined;
77
+ const openFullFrames = openBlock?.fullFrames ?? null;
78
+
79
+ // A fresh segment resets the cursor to frame 0.
80
+ useEffect(() => {
81
+ setFrame(0);
82
+ const v = videoRef.current;
83
+ if (v) {
84
+ try {
85
+ v.currentTime = 0;
86
+ } catch {
87
+ /* not yet seekable — the metadata load handles the first paint */
88
+ }
89
+ }
90
+ }, [openIndex]);
91
+
92
+ function seekTo(f: number) {
93
+ const clamped = clampFrame(f, openFullFrames);
94
+ setFrame(clamped);
95
+ const v = videoRef.current;
96
+ if (v) {
97
+ try {
98
+ v.currentTime = frameToTime(clamped, effFps);
99
+ } catch {
100
+ /* ignore — a not-yet-ready element seeks on its own metadata load */
101
+ }
102
+ }
103
+ }
104
+
105
+ // The native <video> scrubber is a second way to move the cursor: mirror its time
106
+ // back into the frame readout/slider. We NEVER re-seek here (only explicit slider /
107
+ // step handlers seek), so there is no feedback loop.
108
+ function syncFromVideo() {
109
+ const v = videoRef.current;
110
+ if (!v) return;
111
+ const f = clampFrame(timeToFrame(v.currentTime, effFps), openFullFrames);
112
+ setFrame((cur) => (cur === f ? cur : f));
113
+ }
114
+
115
+ const maxFrame = openFullFrames != null && openFullFrames > 0 ? openFullFrames - 1 : 0;
116
+ // Branching from a NON-final authored row cannot keep the old tail as a sibling in a
117
+ // linear v0 — it truncates. Surface that in the action so the click is never silent.
118
+ const droppedCount = openIndex != null ? Math.max(0, goalCount - (openIndex + 1)) : 0;
119
+ const willTruncate = droppedCount > 0;
120
+
121
+ return (
122
+ <section aria-label="Spliced timeline row" style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
123
+ <p className="vi-comfy-label" style={{ margin: 0 }}>
124
+ Spliced timeline — {blocks.length} segment(s), conjoined at {splices.length} splice
125
+ {splices.length === 1 ? "" : "s"}
126
+ </p>
127
+
128
+ {/* ── the row: blocks sized to EFFECTIVE frames, scissor notch at each join ── */}
129
+ <div
130
+ role="list"
131
+ aria-label="Movie segments"
132
+ style={{
133
+ display: "flex",
134
+ alignItems: "stretch",
135
+ width: "100%",
136
+ border: "1px solid var(--vi-border)",
137
+ borderRadius: "0.5rem",
138
+ overflow: "hidden",
139
+ background: "#000",
140
+ }}
141
+ >
142
+ {/* One flatMap over the blocks: each yields its block button, then (when the
143
+ block is a joint parent) a splice NOTCH — flat siblings in the flex row, each
144
+ keyed on its own intrinsic element (never on a custom component). */}
145
+ {blocks.flatMap((b, i) => {
146
+ const ref = segmentRefs[b.index];
147
+ const isOpen = openIndex === b.index;
148
+ const splice = spliceAfter.get(b.index);
149
+ const pct = Math.max(2, b.widthPct); // never let a hard-trimmed block vanish
150
+ const blockEl = (
151
+ <button
152
+ key={b.segmentId ?? `blk-${b.index}`}
153
+ type="button"
154
+ role="listitem"
155
+ onClick={() => setOpenIndex((cur) => (cur === b.index ? null : b.index))}
156
+ title={
157
+ ref
158
+ ? `Segment ${b.index + 1} — scrub & branch (${b.effectiveFrames} frame${
159
+ b.effectiveFrames === 1 ? "" : "s"
160
+ } in the cut${b.trimmed ? ", trimmed at the splice" : ""})`
161
+ : `Segment ${b.index + 1} — no streamable clip`
162
+ }
163
+ aria-pressed={isOpen}
164
+ disabled={!ref}
165
+ style={{
166
+ position: "relative",
167
+ flex: `${pct} 1 0`,
168
+ minWidth: 0,
169
+ minHeight: BLOCK_MIN_H,
170
+ padding: 0,
171
+ margin: 0,
172
+ border: "none",
173
+ borderRight:
174
+ i < blocks.length - 1 && !splice ? "1px solid var(--vi-border)" : "none",
175
+ cursor: ref ? "pointer" : "default",
176
+ background: isOpen ? "var(--vi-accent)" : "transparent",
177
+ outline: isOpen ? "2px solid var(--vi-accent)" : "none",
178
+ outlineOffset: "-2px",
179
+ overflow: "hidden",
180
+ }}
181
+ >
182
+ {ref ? (
183
+ <video
184
+ src={`${mediaBytesUrl(ref.uri)}#t=0.1`}
185
+ muted
186
+ playsInline
187
+ preload="metadata"
188
+ aria-hidden="true"
189
+ style={{
190
+ position: "absolute",
191
+ inset: 0,
192
+ width: "100%",
193
+ height: "100%",
194
+ objectFit: "cover",
195
+ opacity: isOpen ? 0.55 : 0.8,
196
+ }}
197
+ />
198
+ ) : null}
199
+ <span
200
+ aria-hidden="true"
201
+ style={{
202
+ position: "absolute",
203
+ left: 4,
204
+ top: 3,
205
+ fontSize: "0.7rem",
206
+ fontWeight: 700,
207
+ fontVariantNumeric: "tabular-nums",
208
+ color: "#fff",
209
+ textShadow: "0 1px 2px #000",
210
+ padding: "0 0.25rem",
211
+ borderRadius: "0.25rem",
212
+ background: "rgba(0,0,0,0.35)",
213
+ }}
214
+ >
215
+ {b.index + 1}
216
+ {b.trimmed ? " ✂" : ""}
217
+ </span>
218
+ <span
219
+ aria-hidden="true"
220
+ style={{
221
+ position: "absolute",
222
+ left: 4,
223
+ bottom: 3,
224
+ fontSize: "0.62rem",
225
+ fontVariantNumeric: "tabular-nums",
226
+ color: "#fff",
227
+ textShadow: "0 1px 2px #000",
228
+ opacity: 0.9,
229
+ }}
230
+ >
231
+ {b.effectiveFrames}f
232
+ </span>
233
+ </button>
234
+ );
235
+ if (!splice) return [blockEl];
236
+ // A "cut" splice is a HARD scene cut: the parent plays in FULL (no trim) and the
237
+ // next segment is a fresh render — in an identity movie the SUBJECT carries across
238
+ // the cut even though no frame does. Every other splice trims the parent at a frame.
239
+ const isCut = splice.mode === "cut";
240
+ const notchAria = isCut
241
+ ? "Scene cut — identity carried, no frame carry"
242
+ : `Splice — parent frame ${splice.branchFrame}`;
243
+ const notchTitle = isCut
244
+ ? "✂ scene cut — identity carried, no frame carry (the parent plays in full; the next segment is a fresh render)"
245
+ : `✂ Splice at frame ${splice.branchFrame} — the parent is trimmed to ${splice.trimFrames} frame${
246
+ splice.trimFrames === 1 ? "" : "s"
247
+ }; the next segment diverges here.`;
248
+ const notch = (
249
+ <span
250
+ key={`splice-${splice.afterIndex}-${splice.childIndex}`}
251
+ role="separator"
252
+ aria-label={notchAria}
253
+ title={notchTitle}
254
+ style={{
255
+ flex: "0 0 auto",
256
+ width: "1.15rem",
257
+ alignSelf: "stretch",
258
+ display: "flex",
259
+ alignItems: "center",
260
+ justifyContent: "center",
261
+ background:
262
+ "repeating-linear-gradient(45deg, var(--vi-accent) 0 3px, transparent 3px 6px)",
263
+ borderLeft: "1px solid var(--vi-border)",
264
+ borderRight: "1px solid var(--vi-border)",
265
+ color: "#fff",
266
+ fontSize: "0.7rem",
267
+ cursor: "help",
268
+ }}
269
+ >
270
+ ✂
271
+ </span>
272
+ );
273
+ return [blockEl, notch];
274
+ })}
275
+ </div>
276
+
277
+ {/* ── the branch-frame scrubber (opens over the clicked segment's clip) ── */}
278
+ {openBlock && openRef ? (
279
+ <div
280
+ style={{
281
+ border: "1px solid var(--vi-border)",
282
+ borderRadius: "0.5rem",
283
+ padding: "0.6rem 0.7rem",
284
+ display: "flex",
285
+ flexDirection: "column",
286
+ gap: "0.5rem",
287
+ }}
288
+ >
289
+ <div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
290
+ <span style={{ fontWeight: 600 }}>
291
+ Scrub segment {openBlock.index + 1}
292
+ {openBlock.capability ? ` · ${openBlock.capability}` : ""}
293
+ </span>
294
+ <span style={{ flex: 1 }} />
295
+ <button
296
+ type="button"
297
+ className="vi-btn vi-btn-sm vi-btn-ghost"
298
+ onClick={() => setOpenIndex(null)}
299
+ title="Close scrubber"
300
+ >
301
+ ✕
302
+ </button>
303
+ </div>
304
+
305
+ <video
306
+ key={openRef.uri}
307
+ ref={videoRef}
308
+ src={mediaBytesUrl(openRef.uri)}
309
+ controls
310
+ playsInline
311
+ preload="metadata"
312
+ onSeeked={syncFromVideo}
313
+ onTimeUpdate={syncFromVideo}
314
+ onLoadedMetadata={syncFromVideo}
315
+ style={{
316
+ width: "100%",
317
+ maxHeight: "40vh",
318
+ borderRadius: "0.4rem",
319
+ background: "#000",
320
+ }}
321
+ />
322
+
323
+ <div style={{ display: "flex", alignItems: "center", gap: "0.5rem", flexWrap: "wrap" }}>
324
+ <button
325
+ type="button"
326
+ className="vi-btn vi-btn-sm vi-btn-ghost"
327
+ onClick={() => seekTo(frame - 1)}
328
+ disabled={frame <= 0}
329
+ title="Step back one frame"
330
+ >
331
+ −1
332
+ </button>
333
+ <input
334
+ type="range"
335
+ min={0}
336
+ max={maxFrame}
337
+ step={1}
338
+ value={Math.min(frame, maxFrame)}
339
+ onChange={(e) => seekTo(Number(e.target.value))}
340
+ disabled={maxFrame <= 0}
341
+ aria-label="Frame"
342
+ style={{ flex: "1 1 12rem", minWidth: "8rem" }}
343
+ />
344
+ <button
345
+ type="button"
346
+ className="vi-btn vi-btn-sm vi-btn-ghost"
347
+ onClick={() => seekTo(frame + 1)}
348
+ disabled={maxFrame > 0 && frame >= maxFrame}
349
+ title="Step forward one frame"
350
+ >
351
+ +1
352
+ </button>
353
+ <span
354
+ className="vi-knob-hint"
355
+ style={{ fontVariantNumeric: "tabular-nums", minWidth: "9rem", textAlign: "right" }}
356
+ >
357
+ frame {frame}
358
+ {openFullFrames != null ? ` / ${openFullFrames - 1}` : ""} · t=
359
+ {frameToTime(frame, effFps).toFixed(2)}s
360
+ </span>
361
+ </div>
362
+
363
+ <div style={{ display: "flex", alignItems: "center", gap: "0.6rem", flexWrap: "wrap" }}>
364
+ <button
365
+ type="button"
366
+ className="vi-btn vi-btn-accent"
367
+ onClick={() => onBranch(openBlock.index, frame)}
368
+ >
369
+ Branch from frame {frame}
370
+ {willTruncate ? ` — replaces segment${droppedCount === 1 ? "" : "s"} ${openBlock.index + 2}+` : ""}
371
+ </button>
372
+ <span className="vi-comfy-hint" role="note" style={{ margin: 0 }}>
373
+ {willTruncate
374
+ ? `Adds a new segment branching from frame ${frame} of this clip and DROPS the ${droppedCount} authored segment(s) after it — a linear timeline can't hold the old tail as a sibling take (take-trees are the next slice). Then edit its prompt and re-Generate.`
375
+ : `Adds a new segment branching i2v from frame ${frame} of this clip. Then edit its prompt and re-Generate — a studio job is immutable, so this renders a NEW extended movie.`}
376
+ </span>
377
+ </div>
378
+ </div>
379
+ ) : null}
380
+ </section>
381
+ );
382
+ }
383
+
384
+ // ── the PENDING row (shown WHILE a movie renders): one block per authored goal,
385
+ // tinted by live per-segment progress. Equal widths — effective frames aren't
386
+ // known until each clip exists. Rendered once by the composer, never in a .map. ──
387
+ export interface StudioPendingRowProps {
388
+ goalPrompts: string[];
389
+ /** Clean per-segment progress (the composer normalizes the zod-passthrough poll blob
390
+ * into this shape before passing it — keeps the row free of zod-internal typing). */
391
+ progressSegments?: Array<{
392
+ index: number;
393
+ status?: string;
394
+ resumed?: boolean;
395
+ }>;
396
+ }
397
+
398
+ export function StudioPendingRow({ goalPrompts, progressSegments }: StudioPendingRowProps) {
399
+ const statusByIndex = new Map<number, string>();
400
+ (progressSegments ?? []).forEach((s) => {
401
+ const label = s.resumed ? "resumed" : s.status ?? "";
402
+ statusByIndex.set(s.index, label);
403
+ });
404
+
405
+ // Static tints only — the "generating" block reads as active via a brighter fill
406
+ // (no CSS keyframe: app.css is read-only in this slice, so nothing animated is added).
407
+ function tint(status: string): { bg: string; active: boolean } {
408
+ if (status === "done" || status === "resumed") return { bg: "var(--vi-accent)", active: false };
409
+ if (status === "generating" || status === "branching")
410
+ return { bg: "rgba(138,180,255,0.5)", active: true };
411
+ return { bg: "rgba(255,255,255,0.06)", active: false }; // pending / queued
412
+ }
413
+
414
+ return (
415
+ <section aria-label="Spliced timeline row (rendering)" style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}>
416
+ <p className="vi-comfy-label" style={{ margin: 0 }}>
417
+ Spliced timeline — rendering {goalPrompts.length} segment(s)
418
+ </p>
419
+ <div
420
+ role="list"
421
+ style={{
422
+ display: "flex",
423
+ alignItems: "stretch",
424
+ width: "100%",
425
+ border: "1px solid var(--vi-border)",
426
+ borderRadius: "0.5rem",
427
+ overflow: "hidden",
428
+ }}
429
+ >
430
+ {goalPrompts.map((prompt, i) => {
431
+ const status = statusByIndex.get(i) ?? "";
432
+ const { bg, active } = tint(status);
433
+ return (
434
+ <div
435
+ key={`pending-${i}`}
436
+ role="listitem"
437
+ title={`Segment ${i + 1}${status ? ` · ${status}` : " · pending"}${prompt ? ` — ${prompt}` : ""}`}
438
+ style={{
439
+ position: "relative",
440
+ flex: "1 1 0",
441
+ minWidth: 0,
442
+ minHeight: BLOCK_MIN_H,
443
+ background: bg,
444
+ borderRight: i < goalPrompts.length - 1 ? "1px solid var(--vi-border)" : "none",
445
+ display: "flex",
446
+ alignItems: "center",
447
+ justifyContent: "center",
448
+ gap: "0.3rem",
449
+ boxShadow: active ? "inset 0 0 0 2px var(--vi-accent)" : undefined,
450
+ }}
451
+ >
452
+ <span
453
+ style={{
454
+ fontSize: "0.7rem",
455
+ fontWeight: 700,
456
+ fontVariantNumeric: "tabular-nums",
457
+ color: "#fff",
458
+ textShadow: "0 1px 2px #000",
459
+ }}
460
+ >
461
+ {i + 1}
462
+ </span>
463
+ <span style={{ fontSize: "0.62rem", color: "#fff", opacity: 0.85, textShadow: "0 1px 2px #000" }}>
464
+ {status || "pending"}
465
+ </span>
466
+ </div>
467
+ );
468
+ })}
469
+ </div>
470
+ <p className="vi-comfy-hint" role="note" style={{ margin: 0 }}>
471
+ Blocks fill as each segment renders; the spliced row with per-splice frame markers
472
+ and the branch scrubber appear once the movie completes.
473
+ </p>
474
+ </section>
475
+ );
476
+ }