@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,164 @@
1
+ // ImageCropCore — the reusable body of the Image Crop station (Studio slice 2a).
2
+ // This is the station's "Components view" MINUS the upload bar: the
3
+ // SpatialRegionEditor + the useCropJobs enqueue hook + the result grid, all
4
+ // parameterised by a `source` ref. The station (or, in slice 2b, an inline-on-item
5
+ // surface) supplies the source; nothing here uploads or ingests.
6
+ //
7
+ // Behaviour is IDENTICAL to the pre-slice-2a ImageCropStation body: same
8
+ // `vi-station-components` shell, same SpatialRegionEditor props, same run footer,
9
+ // same `vi-result-grid` + JobCard. The one addition is `onProduced` — an OPTIONAL
10
+ // sink slice 2b wires to catch each finished crop; when it is OMITTED (the station
11
+ // case) the Core keeps the station's exact behaviour (crops land in the library via
12
+ // the tracker, the grid renders unchanged).
13
+ //
14
+ // Source lifecycle: the station owned `reset()` + clearing the queued regions
15
+ // inside onPick/clearSource. The Core now owns the `queued` state and the hook, so
16
+ // it resets both whenever the `source` REFERENCE changes (i.e. a new pick or a
17
+ // clear) — mirroring the station's per-pick reset. The initial mount is skipped
18
+ // (prevSource seed) so a remount after a sub-tab switch does NOT wipe the durable
19
+ // tracker's finished crops (they persist across tab switches by design).
20
+ import { useEffect, useRef, useState } from "react";
21
+ import { SpatialRegionEditor } from "../../regions/SpatialRegionEditor";
22
+ import type { SpatialRegion } from "../../regions/types";
23
+ import { useCropJobs, type TrackedJob } from "../useCropJobs";
24
+ import { STILL_RUNNING_MESSAGE } from "../pollCaps";
25
+ import { mediaBytesUrl } from "../../config";
26
+ import type { MediaRef } from "../../video/contract";
27
+
28
+ export interface ImageCropCoreProps {
29
+ /** Source image (kind==="image"), or null before one is loaded. */
30
+ source: MediaRef | null;
31
+ /** Multi-crop queue (station) vs single crop (inline-on-item, slice 2b). */
32
+ multiRegion?: boolean;
33
+ /** Additive (slice 2b): notified with each finished crop's ref + its region.
34
+ Omitted by the station — the crop still lands in the library via the tracker. */
35
+ onProduced?: (
36
+ ref: MediaRef,
37
+ meta: { spatial?: SpatialRegion | null; temporal?: null },
38
+ ) => void;
39
+ }
40
+
41
+ export function ImageCropCore({
42
+ source,
43
+ multiRegion = true,
44
+ onProduced,
45
+ }: ImageCropCoreProps) {
46
+ const [queued, setQueued] = useState<SpatialRegion[]>([]);
47
+ const { jobs, runCrops, resume, reset } = useCropJobs(source);
48
+
49
+ // Reset the queue + this station's tracker rows when a NEW source is picked (or
50
+ // cleared) — keyed on the source reference so it fires exactly on a pick/clear,
51
+ // never on an unrelated re-render. The mount is skipped (prevSource seed) so a
52
+ // remount after a tab switch keeps the durable tracker's finished crops.
53
+ const prevSource = useRef<MediaRef | null>(source);
54
+ useEffect(() => {
55
+ if (prevSource.current === source) return;
56
+ prevSource.current = source;
57
+ setQueued([]);
58
+ reset();
59
+ // eslint-disable-next-line react-hooks/exhaustive-deps
60
+ }, [source]);
61
+
62
+ // Additive slice-2b sink: fire once per finished crop. Inert when onProduced is
63
+ // omitted (the station case) — the library push is owned by the tracker regardless.
64
+ const notified = useRef<Set<string>>(new Set());
65
+ useEffect(() => {
66
+ if (!onProduced) return;
67
+ for (const j of jobs) {
68
+ if (j.status === "done" && j.output && !notified.current.has(j.key)) {
69
+ notified.current.add(j.key);
70
+ onProduced(j.output, { spatial: j.region, temporal: null });
71
+ }
72
+ }
73
+ }, [jobs, onProduced]);
74
+
75
+ return (
76
+ <div className="vi-station-components">
77
+ {/* Image view + crop/manipulation options — ALWAYS rendered. With
78
+ no image loaded the editor shows its full chrome over a blank
79
+ canvas; loading an image only fills the canvas. */}
80
+ <SpatialRegionEditor
81
+ source={source}
82
+ values={queued}
83
+ onChange={(next) => setQueued(Array.isArray(next) ? next : [next])}
84
+ multiRegion={multiRegion}
85
+ numericInputs
86
+ />
87
+
88
+ {/* Run */}
89
+ <div className="vi-station-footer">
90
+ <div className="vi-station-footer-main">
91
+ <span className="vi-crop-hint">
92
+ Each queued box enqueues one crop job on the bus.
93
+ </span>
94
+ </div>
95
+ <div className="vi-station-footer-run">
96
+ <button
97
+ type="button"
98
+ className="vi-btn vi-btn-accent"
99
+ disabled={!source || queued.length === 0}
100
+ onClick={() => runCrops(queued)}
101
+ >
102
+ Run crop{queued.length === 1 ? "" : "s"}
103
+ {queued.length > 0 ? ` (${queued.length})` : ""}
104
+ </button>
105
+ </div>
106
+ </div>
107
+
108
+ {jobs.length > 0 && (
109
+ <div className="vi-result-grid">
110
+ {jobs.map((j) => (
111
+ <JobCard key={j.key} job={j} onResume={() => resume(j.key)} />
112
+ ))}
113
+ </div>
114
+ )}
115
+ </div>
116
+ );
117
+ }
118
+
119
+ const STATUS_TEXT: Record<string, string> = {
120
+ queued: "queued",
121
+ claimed: "claimed",
122
+ running: "running",
123
+ done: "done",
124
+ failed: "failed",
125
+ };
126
+
127
+ function JobCard({ job, onResume }: { job: TrackedJob; onResume: () => void }) {
128
+ const statusKey = job.status ?? "queued";
129
+ const done = job.status === "done";
130
+ const failed = job.status === "failed";
131
+ return (
132
+ <figure className="vi-result-card">
133
+ <div className="vi-result-head">
134
+ <span className="vi-region-idx">#{job.index}</span>
135
+ <span className={`vi-status vi-status-${statusKey}`}>{STATUS_TEXT[statusKey]}</span>
136
+ <code className="vi-region-dims">
137
+ {job.region.w}×{job.region.h} @ {job.region.x},{job.region.y}
138
+ </code>
139
+ </div>
140
+ <div className="vi-result-body">
141
+ {done && job.output ? (
142
+ <img
143
+ className="vi-result-img"
144
+ src={mediaBytesUrl(job.output.uri)}
145
+ alt={`Crop ${job.index}`}
146
+ />
147
+ ) : failed ? (
148
+ <p className="vi-error">{job.error ?? "Crop job failed."}</p>
149
+ ) : job.capped ? (
150
+ <div className="vi-result-pending">
151
+ <p className="vi-crop-hint">{STILL_RUNNING_MESSAGE}</p>
152
+ <button type="button" className="vi-btn vi-btn-sm" onClick={onResume}>
153
+ Check again
154
+ </button>
155
+ </div>
156
+ ) : (
157
+ <div className="vi-result-pending" aria-hidden>
158
+ {STATUS_TEXT[statusKey]}…
159
+ </div>
160
+ )}
161
+ </div>
162
+ </figure>
163
+ );
164
+ }
@@ -0,0 +1,193 @@
1
+ // VideoCropCore — the NEW op primitive of Studio slice 2a: a spatial+temporal crop
2
+ // of a VIDEO in ONE pass. It renders the SpatialRegionEditor (a native-px bbox over
3
+ // the video frame) AND the TemporalRegionEditor (a [start,end) window over the
4
+ // clip's timeline) together, and enqueues a SINGLE `/video/jobs/crop` job carrying
5
+ // BOTH axes via useVideoCropJobs — because the crop backend applies spatial + temporal
6
+ // in one ffmpeg pass when the CropSpec carries both (`-ss <start> -i <in> -t <dur> -vf
7
+ // crop=w:h:x:y`). It is NOT a two-job chain.
8
+ //
9
+ // This Core is additive: it is not wired into any station in slice 2a (no new
10
+ // user-facing surface — that is slice 2b). It exists as the reusable body 2b will
11
+ // mount inline-on-item. It follows the crop-core shape: a `source` ref + an OPTIONAL
12
+ // `onProduced` sink, the same `vi-station-components` shell + `vi-result-grid`.
13
+ //
14
+ // The result card is inlined onto an intrinsic <figure key=…> (the codebase's idiom
15
+ // for a keyed map — mirrors SpatialRegionEditor's <li key> and FrameExtractCore's
16
+ // <button key>), so this NEW file stays free of the bare-tsc `key`-prop artifact.
17
+ //
18
+ // Duration guard: a spatial crop needs the video's native width/height (always
19
+ // present for a video); a temporal trim needs a real duration. Mirroring how the
20
+ // Audio Crop station reads `duration = source.duration_s ?? 0`, when the source
21
+ // reports NO duration the Run is DISABLED (and a hint shown) rather than sending a
22
+ // bad interval to the backend. Both axes must be set to run (a video crop is, by
23
+ // definition, both a box and a window).
24
+ import { useEffect, useRef, useState } from "react";
25
+ import { SpatialRegionEditor } from "../../regions/SpatialRegionEditor";
26
+ import { TemporalRegionEditor } from "../../regions/TemporalRegionEditor";
27
+ import type { SpatialRegion, TemporalRegion } from "../../regions/types";
28
+ import { useVideoCropJobs } from "./useVideoCropJobs";
29
+ import { STILL_RUNNING_MESSAGE } from "../pollCaps";
30
+ import { mediaBytesUrl } from "../../config";
31
+ import type { MediaRef } from "../../video/contract";
32
+
33
+ const STATUS_TEXT: Record<string, string> = {
34
+ queued: "queued",
35
+ claimed: "claimed",
36
+ running: "running",
37
+ done: "done",
38
+ failed: "failed",
39
+ };
40
+
41
+ export interface VideoCropCoreProps {
42
+ /** Source video (kind==="video"), or null before one is loaded. */
43
+ source: MediaRef | null;
44
+ /** Additive (slice 2b): notified with each finished clip's ref + both axes.
45
+ Omitted by callers — the clip still lands in the library via the tracker. */
46
+ onProduced?: (
47
+ ref: MediaRef,
48
+ meta: { spatial?: SpatialRegion | null; temporal?: TemporalRegion | null },
49
+ ) => void;
50
+ }
51
+
52
+ export function VideoCropCore({ source, onProduced }: VideoCropCoreProps) {
53
+ // A video crop is a single {spatial, temporal} pair → ONE job.
54
+ const [spatial, setSpatial] = useState<SpatialRegion | null>(null);
55
+ const [temporal, setTemporal] = useState<TemporalRegion | null>(null);
56
+
57
+ const { jobs, runCrop, resume, reset } = useVideoCropJobs(source);
58
+
59
+ const duration = source?.duration_s ?? 0;
60
+ const hasDuration = duration > 0;
61
+
62
+ // Reset the working pair + this station's tracker rows when a NEW source is picked
63
+ // (or cleared) — keyed on the source reference, mount skipped (prevSource seed).
64
+ const prevSource = useRef<MediaRef | null>(source);
65
+ useEffect(() => {
66
+ if (prevSource.current === source) return;
67
+ prevSource.current = source;
68
+ setSpatial(null);
69
+ setTemporal(null);
70
+ reset();
71
+ // eslint-disable-next-line react-hooks/exhaustive-deps
72
+ }, [source]);
73
+
74
+ // Additive sink: fire once per finished clip. Inert when onProduced is omitted —
75
+ // the library push is owned by the tracker regardless.
76
+ const notified = useRef<Set<string>>(new Set());
77
+ useEffect(() => {
78
+ if (!onProduced) return;
79
+ for (const j of jobs) {
80
+ if (j.status === "done" && j.output && !notified.current.has(j.key)) {
81
+ notified.current.add(j.key);
82
+ onProduced(j.output, { spatial: j.spatial, temporal: j.temporal });
83
+ }
84
+ }
85
+ }, [jobs, onProduced]);
86
+
87
+ const canRun = !!source && !!spatial && !!temporal && hasDuration;
88
+
89
+ return (
90
+ <div className="vi-station-components">
91
+ {/* Spatial box over the video frame (native px). Single-region: "Set crop". */}
92
+ <SpatialRegionEditor
93
+ source={source}
94
+ onChange={(next) => setSpatial(Array.isArray(next) ? next[0] ?? null : next)}
95
+ multiRegion={false}
96
+ numericInputs
97
+ />
98
+
99
+ {/* Temporal window over the clip timeline. When the source reports no duration
100
+ the editor cannot place a valid window — show a hint and gate Run. */}
101
+ {hasDuration ? (
102
+ <TemporalRegionEditor
103
+ source={source}
104
+ duration={duration}
105
+ onChange={(next) => setTemporal(Array.isArray(next) ? next[0] ?? null : next)}
106
+ multiRegion={false}
107
+ numericInputs
108
+ />
109
+ ) : (
110
+ <p className="vi-crop-hint">
111
+ {source
112
+ ? "This video reports no duration — temporal trimming is unavailable."
113
+ : "Load a video to set a spatial box and a temporal window."}
114
+ </p>
115
+ )}
116
+
117
+ {/* Run */}
118
+ <div className="vi-station-footer">
119
+ <div className="vi-station-footer-main">
120
+ <span className="vi-crop-hint">
121
+ One crop job trims the video to the box AND the window in a single pass.
122
+ </span>
123
+ </div>
124
+ <div className="vi-station-footer-run">
125
+ <button
126
+ type="button"
127
+ className="vi-btn vi-btn-accent"
128
+ disabled={!canRun}
129
+ onClick={() => runCrop(spatial, temporal)}
130
+ >
131
+ Run video crop
132
+ </button>
133
+ </div>
134
+ </div>
135
+
136
+ {jobs.length > 0 && (
137
+ <div className="vi-result-grid">
138
+ {jobs.map((j) => {
139
+ const statusKey = j.status ?? "queued";
140
+ const done = j.status === "done";
141
+ const failed = j.status === "failed";
142
+ const box = j.spatial
143
+ ? `${j.spatial.w}×${j.spatial.h} @ ${j.spatial.x},${j.spatial.y}`
144
+ : "full frame";
145
+ const win = j.temporal
146
+ ? ` · ${j.temporal.start_s.toFixed(2)}–${j.temporal.end_s.toFixed(2)}s`
147
+ : "";
148
+ return (
149
+ <figure key={j.key} className="vi-result-card">
150
+ <div className="vi-result-head">
151
+ <span className="vi-region-idx">#{j.index}</span>
152
+ <span className={`vi-status vi-status-${statusKey}`}>
153
+ {STATUS_TEXT[statusKey]}
154
+ </span>
155
+ <code className="vi-region-dims">
156
+ {box}
157
+ {win}
158
+ </code>
159
+ </div>
160
+ <div className="vi-result-body">
161
+ {done && j.output ? (
162
+ <video
163
+ controls
164
+ className="vi-result-img"
165
+ src={mediaBytesUrl(j.output.uri)}
166
+ />
167
+ ) : failed ? (
168
+ <p className="vi-error">{j.error ?? "Crop job failed."}</p>
169
+ ) : j.capped ? (
170
+ <div className="vi-result-pending">
171
+ <p className="vi-crop-hint">{STILL_RUNNING_MESSAGE}</p>
172
+ <button
173
+ type="button"
174
+ className="vi-btn vi-btn-sm"
175
+ onClick={() => resume(j.key)}
176
+ >
177
+ Check again
178
+ </button>
179
+ </div>
180
+ ) : (
181
+ <div className="vi-result-pending" aria-hidden>
182
+ {STATUS_TEXT[statusKey]}…
183
+ </div>
184
+ )}
185
+ </div>
186
+ </figure>
187
+ );
188
+ })}
189
+ </div>
190
+ )}
191
+ </div>
192
+ );
193
+ }
@@ -0,0 +1,224 @@
1
+ // useVideoCropJobs — the Video Crop enqueue surface (Studio slice 2a). The
2
+ // spatial+temporal sibling of useCropJobs: it POSTs ONE crop job carrying BOTH a
3
+ // spatial bbox AND a temporal window, because the crop backend applies both in a
4
+ // SINGLE ffmpeg pass (`-ss <start> -i <in> -t <dur> -vf crop=w:h:x:y`) when the
5
+ // CropSpec carries both axes — so a video crop is one `/video/jobs/crop` job, NOT a
6
+ // two-job chain. (useCropJobs hardcodes `temporal: null`; useAudioCropJobs hardcodes
7
+ // `spatial: null`; this hook sets both.)
8
+ //
9
+ // Everything else mirrors useCropJobs: it owns only request-body construction; the
10
+ // moment it has a job_id it hands the job to jobTracker.trackJob and stops owning
11
+ // polling/job state (poll cadence, caps, terminal parse, the single library push all
12
+ // live in the tracker). `jobs` is DERIVED from the tracker (this station's crop
13
+ // records) merged with any still-enqueuing local placeholders.
14
+ //
15
+ // StationId: this hook rides the dedicated "video-crop" StationId (added to the
16
+ // closed union in jobTracker.ts as part of Studio slice 2b). It was modelled on
17
+ // useCropJobs (which rides "image-crop") and keeps the video crop's spatial region
18
+ // on the tracker record. The dedicated id is REQUIRED now that VideoCropCore is
19
+ // wired ALONGSIDE ImageCropCore on studio items: both filter `station==<id> &&
20
+ // kind==="crop"`, so a shared id would intermix their records — "video-crop" keeps
21
+ // each op's tracker rows (and Video Crop's library origin) cleanly separate.
22
+ import { useCallback, useMemo, useRef, useState } from "react";
23
+ import { request, okValue, errorOf, describeAppError } from "../../transport/client";
24
+ import { hugpyConfig } from "../../config";
25
+ import {
26
+ enqueueResultSchema,
27
+ type CropJobRequest,
28
+ type JobStatus,
29
+ type MediaRef,
30
+ } from "../../video/contract";
31
+ import {
32
+ useJobTracker,
33
+ trackJob,
34
+ resumeJob,
35
+ resumeStation,
36
+ dismissTerminal,
37
+ type TrackedJob as TrackerRecord,
38
+ } from "../../video/jobTracker";
39
+ import type { SpatialRegion, TemporalRegion } from "../../regions/types";
40
+
41
+ export interface TrackedVideoCropJob {
42
+ /** Stable local key (the server job id once enqueued). */
43
+ key: string;
44
+ /** 1-based position, for display. */
45
+ index: number;
46
+ /** The native-px spatial box this job crops (kept on the tracker record). */
47
+ spatial: SpatialRegion | null;
48
+ /** The temporal window (seconds) this job trims — local only (see note below). */
49
+ temporal: TemporalRegion | null;
50
+ /** Server job id once enqueued, else null. */
51
+ jobId: string | null;
52
+ /** Latest polled status (null = enqueued but not yet claimed). */
53
+ status: JobStatus;
54
+ /** Result clip MediaRef on success. */
55
+ output: MediaRef | null;
56
+ /** Human message on failure (enqueue error or job error). */
57
+ error: string | null;
58
+ /** Poll cap reached while non-terminal — NOT a failure. */
59
+ capped: boolean;
60
+ /** Enqueue timestamp — the anchor for the poll cap. */
61
+ startedAt: number;
62
+ }
63
+
64
+ // A pre-enqueue placeholder — see useCropJobs.ts for the rationale. It carries BOTH
65
+ // axes; the tracker record it hands off to only carries the spatial region (its
66
+ // `region` field is a single Region), so `temporal` on a job is surfaced from the
67
+ // placeholder and is null once the tracker owns the row.
68
+ interface Pending {
69
+ key: string;
70
+ index: number;
71
+ spatial: SpatialRegion | null;
72
+ temporal: TemporalRegion | null;
73
+ status: Extract<JobStatus, "queued" | "failed">;
74
+ error: string | null;
75
+ startedAt: number;
76
+ }
77
+
78
+ let _seq = 0;
79
+ function makeKey(): string {
80
+ _seq += 1;
81
+ return `vcrop_${_seq.toString(36)}_${Date.now().toString(36)}`;
82
+ }
83
+
84
+ export interface VideoCropJobsApi {
85
+ jobs: TrackedVideoCropJob[];
86
+ /** Enqueue ONE crop job carrying BOTH the spatial box and the temporal window. */
87
+ runCrop: (spatial: SpatialRegion | null, temporal: TemporalRegion | null) => void;
88
+ /**
89
+ * Re-anchor the poll clock for a capped job (or every capped crop when `key`
90
+ * is omitted) and resume polling. Clears `capped`, keeps status.
91
+ */
92
+ resume: (key?: string) => void;
93
+ /** Forget this station's finished jobs (called when a new source is ingested). */
94
+ reset: () => void;
95
+ }
96
+
97
+ const STATION = "video-crop";
98
+
99
+ export function useVideoCropJobs(source: MediaRef | null): VideoCropJobsApi {
100
+ const records = useJobTracker();
101
+ const [pending, setPending] = useState<Pending[]>([]);
102
+
103
+ // This station's crop records, newest-index last (stable via useMemo).
104
+ const mine = useMemo(
105
+ () => records.filter((r) => r.station === STATION && r.kind === "crop"),
106
+ [records],
107
+ );
108
+
109
+ const mineRef = useRef<TrackerRecord[]>(mine);
110
+ mineRef.current = mine;
111
+ const pendingRef = useRef<Pending[]>(pending);
112
+ pendingRef.current = pending;
113
+
114
+ const jobs = useMemo<TrackedVideoCropJob[]>(() => {
115
+ const fromTracker: TrackedVideoCropJob[] = mine.map((r) => ({
116
+ key: r.jobId,
117
+ index: r.index,
118
+ spatial: (r.region as SpatialRegion) ?? null,
119
+ temporal: null,
120
+ jobId: r.jobId,
121
+ status: r.status,
122
+ output: r.outputs[0] ?? null,
123
+ error: r.error,
124
+ capped: r.capped,
125
+ startedAt: r.startedAt,
126
+ }));
127
+ const fromPending: TrackedVideoCropJob[] = pending.map((p) => ({
128
+ key: p.key,
129
+ index: p.index,
130
+ spatial: p.spatial,
131
+ temporal: p.temporal,
132
+ jobId: null,
133
+ status: p.status,
134
+ output: null,
135
+ error: p.error,
136
+ capped: false,
137
+ startedAt: p.startedAt,
138
+ }));
139
+ return [...fromTracker, ...fromPending].sort((a, b) => a.index - b.index);
140
+ }, [mine, pending]);
141
+
142
+ const reset = useCallback(() => {
143
+ setPending([]);
144
+ dismissTerminal(STATION);
145
+ }, []);
146
+
147
+ const resume = useCallback((key?: string) => {
148
+ if (key != null) resumeJob(key);
149
+ else resumeStation(STATION);
150
+ }, []);
151
+
152
+ const runCrop = useCallback(
153
+ (spatial: SpatialRegion | null, temporal: TemporalRegion | null) => {
154
+ if (!source) return;
155
+ // Continue the 1-based numbering past any existing/pending rows.
156
+ const base = Math.max(
157
+ 0,
158
+ ...mineRef.current.map((r) => r.index),
159
+ ...pendingRef.current.map((p) => p.index),
160
+ );
161
+ const key = makeKey();
162
+ const index = base + 1;
163
+ const enqueuedAt = Date.now();
164
+ const dims = spatial ? `${spatial.w}×${spatial.h}` : "full";
165
+ const win = temporal
166
+ ? ` ${temporal.start_s.toFixed(2)}–${temporal.end_s.toFixed(2)}s`
167
+ : "";
168
+ const label = `clip ${dims}${win}`;
169
+ setPending((prev) => [
170
+ ...prev,
171
+ { key, index, spatial, temporal, status: "queued", error: null, startedAt: enqueuedAt },
172
+ ]);
173
+
174
+ // ONE crop job, BOTH axes — the backend applies spatial + temporal in a single
175
+ // ffmpeg pass when the CropSpec carries both.
176
+ const body: CropJobRequest = { source, spatial, temporal };
177
+ void request<unknown>(hugpyConfig.cropEnqueueUrl, {
178
+ method: "POST",
179
+ body: JSON.stringify(body),
180
+ headers: { "Content-Type": "application/json" },
181
+ meta: { specKey: STATION, operation: "crop.enqueue" },
182
+ }).then((r) => {
183
+ if (!r.ok) {
184
+ setPending((prev) =>
185
+ prev.map((p) =>
186
+ p.key === key
187
+ ? { ...p, status: "failed", error: describeAppError(errorOf(r)) }
188
+ : p,
189
+ ),
190
+ );
191
+ return;
192
+ }
193
+ const parsed = enqueueResultSchema.safeParse(okValue(r));
194
+ if (!parsed.success) {
195
+ setPending((prev) =>
196
+ prev.map((p) =>
197
+ p.key === key
198
+ ? { ...p, status: "failed", error: "Malformed enqueue response." }
199
+ : p,
200
+ ),
201
+ );
202
+ return;
203
+ }
204
+ // Hand the job to the durable tracker, then drop the local placeholder. The
205
+ // tracker record carries the spatial region (single Region slot); the temporal
206
+ // window lives only on the placeholder above.
207
+ trackJob({
208
+ jobId: parsed.data.job_id,
209
+ kind: "crop",
210
+ station: STATION,
211
+ label,
212
+ libLabel: label,
213
+ enqueuedAt,
214
+ index,
215
+ region: spatial,
216
+ });
217
+ setPending((prev) => prev.filter((p) => p.key !== key));
218
+ });
219
+ },
220
+ [source],
221
+ );
222
+
223
+ return { jobs, runCrop, resume, reset };
224
+ }
@@ -0,0 +1,87 @@
1
+ // Phase 8 — shared poll-cap constants + "still running" copy for the five
2
+ // job-poll hooks (useCropJobs, useFrameJobs, useGenerateJob, useAudioExtractJob,
3
+ // useAudioCropJobs). Centralised here so the caps and the human message never
4
+ // drift across the hooks.
5
+ //
6
+ // The old behaviour capped a poll loop at ~60s of wall-clock and marked the job
7
+ // `failed` with "Timed out" — even though the backend often finished the work a
8
+ // moment later (cold model loads especially). Phase 8 does two things:
9
+ // 1. Raises the ceilings substantially (generation cold-loads can take minutes).
10
+ // 2. On cap-expiry, STOPS hammering the poller but does NOT fail the job —
11
+ // instead each hook flips a `capped` flag, keeps the last real status, and
12
+ // exposes a `resume()` that re-anchors the poll clock (startedAt = now,
13
+ // clears capped) so polling simply continues. No SSE, no websockets.
14
+
15
+ /** Generation cold-loads can take minutes — 5 min ceiling before we pause polling. */
16
+ export const GENERATE_POLL_CAP_MS = 300_000;
17
+
18
+ /** Crop / frame-extract / audio-extract / audio-crop — 3 min ceiling. */
19
+ export const JOB_POLL_CAP_MS = 180_000;
20
+
21
+ /**
22
+ * Copy shown when a poll loop hits its cap but the job may still be finishing
23
+ * server-side. Paired with a "Check again" affordance wired to the hook's resume.
24
+ */
25
+ export const STILL_RUNNING_MESSAGE = "Still running on the server — check again.";
26
+
27
+ /**
28
+ * Scene generation (Generate station, Scene mode) produces N frames — each a
29
+ * full text-to-image inference — plus an optional mp4 assemble, so the ceiling
30
+ * scales with n_frames rather than a single flat cap. Base 5 min (a cold model
31
+ * load like generate_image) + 1 min per frame.
32
+ *
33
+ * Chaining (`chain: true`) runs the frames as SEQUENTIAL img2img — each frame
34
+ * conditions on the previous, so they can't overlap and the wall-clock grows.
35
+ * When chaining we add an extra per-frame allowance on top of the base per-frame
36
+ * budget so the cap doesn't trip while a legitimately-slower chained scene runs.
37
+ */
38
+ export const GENERATE_SCENE_POLL_CAP_BASE_MS = 300_000;
39
+ export const GENERATE_SCENE_POLL_CAP_PER_FRAME_MS = 60_000;
40
+ export const GENERATE_SCENE_POLL_CAP_CHAIN_PER_FRAME_MS = 60_000;
41
+
42
+ /**
43
+ * The n_frames-scaled poll cap (ms) for a generate_scene job. `chain` adds extra
44
+ * per-frame time because chained frames run sequentially (img2img).
45
+ */
46
+ export function generateScenePollCapMs(nFrames: number, chain?: boolean): number {
47
+ const frames = Math.max(1, nFrames | 0);
48
+ const perFrame =
49
+ GENERATE_SCENE_POLL_CAP_PER_FRAME_MS +
50
+ (chain ? GENERATE_SCENE_POLL_CAP_CHAIN_PER_FRAME_MS : 0);
51
+ return GENERATE_SCENE_POLL_CAP_BASE_MS + perFrame * frames;
52
+ }
53
+
54
+ /**
55
+ * Movie generation (Generate station, Movie mode) renders MANY segments, each an
56
+ * N-frame scene, plus an mp4 assemble — and with the vision director on, a segment
57
+ * may be re-rolled several times. So the ceiling scales with BOTH the segment count
58
+ * and the total frame count (segments × frames): base 5 min (a cold model load) +
59
+ * 1 min per segment + a per-total-frame budget. Chaining adds extra per-frame time
60
+ * (sequential img2img), matching the scene cap's chain allowance.
61
+ */
62
+ export const GENERATE_MOVIE_POLL_CAP_BASE_MS = 300_000;
63
+ export const GENERATE_MOVIE_POLL_CAP_PER_SEGMENT_MS = 60_000;
64
+ export const GENERATE_MOVIE_POLL_CAP_PER_FRAME_MS = 20_000;
65
+ export const GENERATE_MOVIE_POLL_CAP_CHAIN_PER_FRAME_MS = 15_000;
66
+
67
+ /**
68
+ * The segments×frames-scaled poll cap (ms) for a generate_movie job. `framesTotal`
69
+ * is the timeline's total frame count (`total` = max end_frame); `chain` adds extra
70
+ * per-frame time because chained frames run sequentially (img2img).
71
+ */
72
+ export function generateMoviePollCapMs(
73
+ segments: number,
74
+ framesTotal?: number,
75
+ chain?: boolean,
76
+ ): number {
77
+ const segs = Math.max(1, segments | 0);
78
+ const frames = Math.max(segs, framesTotal ? framesTotal | 0 : segs);
79
+ const perFrame =
80
+ GENERATE_MOVIE_POLL_CAP_PER_FRAME_MS +
81
+ (chain ? GENERATE_MOVIE_POLL_CAP_CHAIN_PER_FRAME_MS : 0);
82
+ return (
83
+ GENERATE_MOVIE_POLL_CAP_BASE_MS +
84
+ GENERATE_MOVIE_POLL_CAP_PER_SEGMENT_MS * segs +
85
+ perFrame * frames
86
+ );
87
+ }