@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,375 @@
1
+ // SpatialRegionEditor — a draggable/resizable bbox over a source image, the first
2
+ // concrete implementation of the RegionEditor contract (regions/types.ts). Used by
3
+ // the Image Crop station in multiRegion mode: the user shapes a working box (drag
4
+ // to move, corner handles to resize, aspect lock, preset buttons, numeric fields),
5
+ // adds it to a queue, and the queue is surfaced to the station via onChange.
6
+ //
7
+ // CRITICAL CORRECTNESS: the box the user manipulates lives in DISPLAY pixels (the
8
+ // image is scaled to fit), but CropSpec regions MUST be in the image's NATIVE pixel
9
+ // space. We keep the working region in native px at all times and convert only for
10
+ // painting/hit-testing, using scale = source.width / renderedWidth. Every emitted
11
+ // region is clamped to integers within [0..width] x [0..height].
12
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
13
+ import type { RegionEditorProps, SpatialPreset, SpatialRegion } from "./types";
14
+ import type { MediaRef } from "../video/contract";
15
+ import { mediaBytesUrl } from "../config";
16
+
17
+ /** Model-native starting boxes offered by default. */
18
+ export const DEFAULT_SPATIAL_PRESETS: readonly SpatialPreset[] = [
19
+ { label: "1:1", aspect: 1 },
20
+ { label: "16:9", aspect: 16 / 9 },
21
+ { label: "9:16", aspect: 9 / 16 },
22
+ { label: "4:3", aspect: 4 / 3 },
23
+ { label: "1024²", aspect: 1, size: { w: 1024, h: 1024 } },
24
+ { label: "Native", aspect: null },
25
+ ] as const;
26
+
27
+ /** Fallback canvas dimensions used to lay out the editor when no image is
28
+ loaded yet — the chrome renders full, just over a blank canvas. */
29
+ const DEFAULT_DIM = 1024;
30
+
31
+ type Corner = "nw" | "ne" | "sw" | "se";
32
+
33
+ interface DragState {
34
+ mode: "move" | "resize";
35
+ corner: Corner | null;
36
+ startClientX: number;
37
+ startClientY: number;
38
+ startBox: SpatialRegion;
39
+ /** native px per display px, captured at drag start. */
40
+ scaleX: number;
41
+ scaleY: number;
42
+ /** locked width/height ratio during a resize, or null when unlocked. */
43
+ aspect: number | null;
44
+ }
45
+
46
+ interface Size {
47
+ w: number;
48
+ h: number;
49
+ }
50
+
51
+ // ---- pure geometry (native px) ------------------------------------------------
52
+
53
+ /** Clamp a region to integer pixels inside [0..W] x [0..H], min 1x1. */
54
+ function clampRegion(r: SpatialRegion, W: number, H: number): SpatialRegion {
55
+ const w = Math.max(1, Math.min(Math.round(r.w), W));
56
+ const h = Math.max(1, Math.min(Math.round(r.h), H));
57
+ const x = Math.max(0, Math.min(Math.round(r.x), W - w));
58
+ const y = Math.max(0, Math.min(Math.round(r.y), H - h));
59
+ return { x, y, w, h };
60
+ }
61
+
62
+ /** Seed a centered region for a preset, clamped to the source. */
63
+ function presetToRegion(p: SpatialPreset, W: number, H: number): SpatialRegion {
64
+ let w: number;
65
+ let h: number;
66
+ if (p.size) {
67
+ w = p.size.w;
68
+ h = p.size.h;
69
+ } else if (p.aspect == null) {
70
+ w = W;
71
+ h = H;
72
+ } else if (W / H > p.aspect) {
73
+ // source is wider than target aspect → height-bound
74
+ h = H;
75
+ w = H * p.aspect;
76
+ } else {
77
+ w = W;
78
+ h = W / p.aspect;
79
+ }
80
+ w = Math.min(w, W);
81
+ h = Math.min(h, H);
82
+ return clampRegion({ x: (W - w) / 2, y: (H - h) / 2, w, h }, W, H);
83
+ }
84
+
85
+ /** The corner opposite the dragged one — stays fixed during a resize. */
86
+ function anchorOf(corner: Corner, b: SpatialRegion): { ax: number; ay: number } {
87
+ const right = b.x + b.w;
88
+ const bottom = b.y + b.h;
89
+ switch (corner) {
90
+ case "nw":
91
+ return { ax: right, ay: bottom };
92
+ case "ne":
93
+ return { ax: b.x, ay: bottom };
94
+ case "sw":
95
+ return { ax: right, ay: b.y };
96
+ case "se":
97
+ return { ax: b.x, ay: b.y };
98
+ }
99
+ }
100
+
101
+ const CORNERS: Corner[] = ["nw", "ne", "sw", "se"];
102
+
103
+ // ---- rendered-size hook -------------------------------------------------------
104
+
105
+ /** Track an element's rendered (display) box, updating on load + resize. */
106
+ function useRenderedSize(ref: React.RefObject<HTMLElement>, dep: unknown): Size {
107
+ const [size, setSize] = useState<Size>({ w: 0, h: 0 });
108
+ useEffect(() => {
109
+ const el = ref.current;
110
+ if (!el) return;
111
+ const measure = () => setSize({ w: el.clientWidth, h: el.clientHeight });
112
+ measure();
113
+ const ro = new ResizeObserver(measure);
114
+ ro.observe(el);
115
+ return () => ro.disconnect();
116
+ // eslint-disable-next-line react-hooks/exhaustive-deps
117
+ }, [dep]);
118
+ return size;
119
+ }
120
+
121
+ // ---- component ----------------------------------------------------------------
122
+
123
+ interface SpatialRegionEditorProps
124
+ extends RegionEditorProps<SpatialRegion, SpatialPreset> {
125
+ /** Source image (kind==="image"), or null before one is loaded — the editor
126
+ then renders its full chrome over a blank default-sized canvas. */
127
+ source: MediaRef | null;
128
+ }
129
+
130
+ export function SpatialRegionEditor({
131
+ source,
132
+ values,
133
+ onChange,
134
+ presets = DEFAULT_SPATIAL_PRESETS as SpatialPreset[],
135
+ numericInputs = true,
136
+ multiRegion = true,
137
+ }: SpatialRegionEditorProps) {
138
+ const W = source?.width || DEFAULT_DIM;
139
+ const H = source?.height || DEFAULT_DIM;
140
+
141
+ // One measuring ref for whichever canvas element renders (the <img>, or the
142
+ // blank placeholder when no image is loaded).
143
+ const canvasElRef = useRef<HTMLElement | null>(null);
144
+ const measureRef = useCallback((el: HTMLElement | null) => {
145
+ canvasElRef.current = el;
146
+ }, []);
147
+ const rendered = useRenderedSize(canvasElRef, source?.uri);
148
+
149
+ const [working, setWorking] = useState<SpatialRegion>(() =>
150
+ presetToRegion({ label: "seed", aspect: null }, W, H),
151
+ );
152
+ const [aspectLock, setAspectLock] = useState(false);
153
+ const [drag, setDrag] = useState<DragState | null>(null);
154
+
155
+ // Reset the working box (to a slightly inset native frame) when the source swaps.
156
+ useEffect(() => {
157
+ setWorking(clampRegion({ x: W * 0.1, y: H * 0.1, w: W * 0.8, h: H * 0.8 }, W, H));
158
+ }, [source?.uri, W, H]);
159
+
160
+ const queue = values ?? [];
161
+
162
+ // display px per native px — the inverse of the native-space scale factor.
163
+ const dispScaleX = rendered.w > 0 && W > 0 ? rendered.w / W : 0;
164
+ const dispScaleY = rendered.h > 0 && H > 0 ? rendered.h / H : 0;
165
+
166
+ const setClamped = useCallback(
167
+ (r: SpatialRegion) => setWorking(clampRegion(r, W, H)),
168
+ [W, H],
169
+ );
170
+
171
+ // ---- drag lifecycle: window listeners while a pointer is down ----
172
+ useEffect(() => {
173
+ if (!drag) return;
174
+ const onMove = (e: PointerEvent) => {
175
+ const dxN = (e.clientX - drag.startClientX) * drag.scaleX;
176
+ const dyN = (e.clientY - drag.startClientY) * drag.scaleY;
177
+ if (drag.mode === "move") {
178
+ setClamped({ ...drag.startBox, x: drag.startBox.x + dxN, y: drag.startBox.y + dyN });
179
+ return;
180
+ }
181
+ const corner = drag.corner as Corner;
182
+ const { ax, ay } = anchorOf(corner, drag.startBox);
183
+ // dragged corner's new native position
184
+ const sc =
185
+ corner === "nw"
186
+ ? { x: drag.startBox.x, y: drag.startBox.y }
187
+ : corner === "ne"
188
+ ? { x: drag.startBox.x + drag.startBox.w, y: drag.startBox.y }
189
+ : corner === "sw"
190
+ ? { x: drag.startBox.x, y: drag.startBox.y + drag.startBox.h }
191
+ : { x: drag.startBox.x + drag.startBox.w, y: drag.startBox.y + drag.startBox.h };
192
+ const cx = sc.x + dxN;
193
+ const cy = sc.y + dyN;
194
+ let w = Math.abs(cx - ax);
195
+ let h = Math.abs(cy - ay);
196
+ if (drag.aspect != null && drag.aspect > 0) h = w / drag.aspect;
197
+ const nx = cx >= ax ? ax : ax - w;
198
+ const ny = cy >= ay ? ay : ay - h;
199
+ setClamped({ x: nx, y: ny, w, h });
200
+ };
201
+ const onUp = () => setDrag(null);
202
+ window.addEventListener("pointermove", onMove);
203
+ window.addEventListener("pointerup", onUp);
204
+ return () => {
205
+ window.removeEventListener("pointermove", onMove);
206
+ window.removeEventListener("pointerup", onUp);
207
+ };
208
+ }, [drag, setClamped]);
209
+
210
+ const startMove = (e: React.PointerEvent) => {
211
+ if (dispScaleX <= 0) return;
212
+ e.preventDefault();
213
+ setDrag({
214
+ mode: "move",
215
+ corner: null,
216
+ startClientX: e.clientX,
217
+ startClientY: e.clientY,
218
+ startBox: working,
219
+ scaleX: 1 / dispScaleX,
220
+ scaleY: 1 / dispScaleY,
221
+ aspect: null,
222
+ });
223
+ };
224
+
225
+ const startResize = (corner: Corner) => (e: React.PointerEvent) => {
226
+ if (dispScaleX <= 0) return;
227
+ e.preventDefault();
228
+ e.stopPropagation();
229
+ setDrag({
230
+ mode: "resize",
231
+ corner,
232
+ startClientX: e.clientX,
233
+ startClientY: e.clientY,
234
+ startBox: working,
235
+ scaleX: 1 / dispScaleX,
236
+ scaleY: 1 / dispScaleY,
237
+ aspect: aspectLock && working.h > 0 ? working.w / working.h : null,
238
+ });
239
+ };
240
+
241
+ // ---- numeric field editing (native px) ----
242
+ const setField = (key: keyof SpatialRegion) => (e: React.ChangeEvent<HTMLInputElement>) => {
243
+ const raw = Number(e.target.value);
244
+ const v = Number.isFinite(raw) ? raw : 0;
245
+ setClamped({ ...working, [key]: v });
246
+ };
247
+
248
+ // ---- queue mutations (surface via onChange) ----
249
+ const addToQueue = () => {
250
+ const region = clampRegion(working, W, H);
251
+ onChange(multiRegion ? [...queue, region] : region);
252
+ };
253
+ const removeAt = (i: number) => onChange(queue.filter((_, idx) => idx !== i));
254
+ const applyPreset = (p: SpatialPreset) => setWorking(presetToRegion(p, W, H));
255
+
256
+ // display geometry for the overlay box
257
+ const boxStyle = useMemo(
258
+ () => ({
259
+ left: `${working.x * dispScaleX}px`,
260
+ top: `${working.y * dispScaleY}px`,
261
+ width: `${working.w * dispScaleX}px`,
262
+ height: `${working.h * dispScaleY}px`,
263
+ }),
264
+ [working, dispScaleX, dispScaleY],
265
+ );
266
+
267
+ return (
268
+ <div className="vi-crop-editor">
269
+ <div className="vi-crop-canvas">
270
+ {source ? (
271
+ <img
272
+ ref={measureRef}
273
+ className="vi-crop-img"
274
+ src={mediaBytesUrl(source.uri)}
275
+ alt="Crop source"
276
+ draggable={false}
277
+ />
278
+ ) : (
279
+ // No image yet — a blank canvas of the default aspect keeps the full
280
+ // editor chrome laid out; only the media itself is missing.
281
+ <div
282
+ ref={measureRef}
283
+ className="vi-crop-img vi-crop-img--blank"
284
+ style={{ aspectRatio: `${W} / ${H}` }}
285
+ aria-hidden
286
+ />
287
+ )}
288
+ <div className="vi-crop-overlay">
289
+ <div
290
+ className={drag?.mode === "move" ? "vi-crop-box vi-crop-box-active" : "vi-crop-box"}
291
+ style={boxStyle}
292
+ onPointerDown={startMove}
293
+ >
294
+ {CORNERS.map((c) => (
295
+ <span
296
+ key={c}
297
+ className={`vi-crop-handle vi-crop-handle-${c}`}
298
+ onPointerDown={startResize(c)}
299
+ />
300
+ ))}
301
+ </div>
302
+ </div>
303
+ </div>
304
+
305
+ <div className="vi-crop-controls">
306
+ <div className="vi-crop-presets">
307
+ {presets.map((p) => (
308
+ <button
309
+ key={p.label}
310
+ type="button"
311
+ className="vi-btn vi-btn-ghost"
312
+ onClick={() => applyPreset(p)}
313
+ >
314
+ {p.label}
315
+ </button>
316
+ ))}
317
+ <label className="vi-crop-lock">
318
+ <input
319
+ type="checkbox"
320
+ checked={aspectLock}
321
+ onChange={(e) => setAspectLock(e.target.checked)}
322
+ />
323
+ Lock aspect
324
+ </label>
325
+ </div>
326
+
327
+ {numericInputs && (
328
+ <div className="vi-crop-fields">
329
+ {(["x", "y", "w", "h"] as const).map((k) => (
330
+ <label key={k} className="vi-crop-field">
331
+ <span>{k}</span>
332
+ <input
333
+ type="number"
334
+ min={0}
335
+ max={k === "x" || k === "w" ? W : H}
336
+ value={working[k]}
337
+ onChange={setField(k)}
338
+ />
339
+ </label>
340
+ ))}
341
+ <span className="vi-crop-native">
342
+ native {W}×{H}
343
+ </span>
344
+ </div>
345
+ )}
346
+
347
+ <div className="vi-crop-actions">
348
+ <button type="button" className="vi-btn vi-btn-accent" onClick={addToQueue}>
349
+ {multiRegion ? "Add crop to queue" : "Set crop"}
350
+ </button>
351
+ </div>
352
+
353
+ {multiRegion && queue.length > 0 && (
354
+ <ul className="vi-region-list">
355
+ {queue.map((r, i) => (
356
+ <li key={i} className="vi-region-item">
357
+ <span className="vi-region-idx">#{i + 1}</span>
358
+ <code className="vi-region-dims">
359
+ {r.w}×{r.h} @ {r.x},{r.y}
360
+ </code>
361
+ <button
362
+ type="button"
363
+ className="vi-btn vi-btn-ghost vi-btn-sm"
364
+ onClick={() => removeAt(i)}
365
+ >
366
+ remove
367
+ </button>
368
+ </li>
369
+ ))}
370
+ </ul>
371
+ )}
372
+ </div>
373
+ </div>
374
+ );
375
+ }