@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.
- package/LICENSE +41 -0
- package/assets/sampels/images/bycycle/bd8603bad35d4f179f4d1134d8faf2ee.mp4 +0 -0
- package/assets/sampels/images/bycycle/frame_00000.png +0 -0
- package/assets/sampels/images/bycycle/frame_00001.png +0 -0
- package/assets/sampels/images/bycycle/frame_00002.png +0 -0
- package/assets/sampels/images/bycycle/frame_00003.png +0 -0
- package/assets/sampels/images/bycycle/frame_00004.png +0 -0
- package/assets/sampels/images/bycycle/frame_00005.png +0 -0
- package/assets/sampels/images/forrest/ff23090611b844a6b85c224f388bd542.mp4 +0 -0
- package/assets/sampels/images/forrest/frame_00000.png +0 -0
- package/assets/sampels/images/forrest/frame_00001.png +0 -0
- package/assets/sampels/images/forrest/frame_00002.png +0 -0
- package/assets/sampels/images/forrest/frame_00003.png +0 -0
- package/assets/sampels/images/forrest/frame_00004.png +0 -0
- package/assets/sampels/images/forrest/frame_00005.png +0 -0
- package/assets/sampels/images/shenzo/e1a4fcd623084ab4a986d11c8cd00db8.mp4 +0 -0
- package/assets/sampels/images/shenzo/frame_00000.png +0 -0
- package/assets/sampels/images/shenzo/frame_00001.png +0 -0
- package/assets/sampels/images/shenzo/frame_00002.png +0 -0
- package/assets/sampels/images/shenzo/frame_00003.png +0 -0
- package/assets/sampels/images/shenzo/frame_00004.png +0 -0
- package/assets/sampels/images/shenzo/frame_00005.png +0 -0
- package/assets/sampels/scenes/man.mp4 +0 -0
- package/assets/sampels/scenes/woman.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/_assembly/contrib_00.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/movie.json +67 -0
- package/assets/sampels/studio_movies/caverns/movie.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/manifest.json +87 -0
- package/assets/sampels/studio_movies/caverns/segment_00/5eea7770a554fef0c3bdc38f8f38b925dcf75f13c015d423e8eadec47d41bf6b/provenance.json +7 -0
- package/assets/sampels/studio_movies/caverns/segment_01/branch.png +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/concat_list.txt +2 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_00.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/_assembly/contrib_01.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/movie.json +81 -0
- package/assets/sampels/studio_movies/keeper-matrix/movie.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/manifest.json +87 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_00/64854c6a21d47ce10b23e884e12de448b25bfb0a375d33238dd0bec8056c076d/provenance.json +7 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/branch.png +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/clip.mp4 +0 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/manifest.json +87 -0
- package/assets/sampels/studio_movies/keeper-matrix/segment_01/cc926b44a42b90d7d291a4e50e3859458306c207a17c718086a3466e4049d6c6/provenance.json +7 -0
- package/assets/sampels/videos/a_red_paper_boat_drifting_on_a_calm_pond__gentle_ripples__mo_a62d6c28.mp4 +0 -0
- package/assets/sampels/videos/aurora.mp4 +0 -0
- package/assets/sampels/videos/dark_night.mp4 +0 -0
- package/assets/sampels/videos/fire_and_soul.mp4 +0 -0
- package/assets/sampels/videos/harsh_seas.mp4 +0 -0
- package/entry.tsx +74 -0
- package/index.html +53 -0
- package/package.json +42 -0
- package/src/assets/hugpy-mark.png +0 -0
- package/src/config.ts +676 -0
- package/src/demo/DemoBanner.tsx +54 -0
- package/src/demo/assets/demo-audio.mp3 +0 -0
- package/src/demo/assets/demo-clip.mp4 +0 -0
- package/src/demo/assets/demo-crop.mp4 +0 -0
- package/src/demo/assets/demo-poster.jpg +0 -0
- package/src/demo/assets/frame-1.jpg +0 -0
- package/src/demo/assets/frame-2.jpg +0 -0
- package/src/demo/assets/frame-3.jpg +0 -0
- package/src/demo/assets/frame-4.jpg +0 -0
- package/src/demo/assets/frame-5.jpg +0 -0
- package/src/demo/assets/frame-6.jpg +0 -0
- package/src/demo/assets/gen-1.jpg +0 -0
- package/src/demo/assets/gen-2.jpg +0 -0
- package/src/demo/assets.d.ts +14 -0
- package/src/demo/demo.css +64 -0
- package/src/demo/demoFetch.ts +305 -0
- package/src/demo/fixtures.ts +814 -0
- package/src/demo/index.ts +43 -0
- package/src/demo/mode.ts +81 -0
- package/src/demo/promptAssist.ts +181 -0
- package/src/demo/seed.ts +97 -0
- package/src/nav/BrandMark.css +66 -0
- package/src/nav/BrandMark.tsx +40 -0
- package/src/nav/Navbar.css +13 -0
- package/src/nav/Navbar.tsx +68 -0
- package/src/regions/SpatialRegionEditor.tsx +375 -0
- package/src/regions/TemporalRegionEditor.tsx +485 -0
- package/src/regions/types.ts +85 -0
- package/src/session.ts +85 -0
- package/src/share.ts +77 -0
- package/src/stations/ActivePanel.tsx +55 -0
- package/src/stations/ActiveProcessesStation.tsx +272 -0
- package/src/stations/ActiveProcessesStation.tsx.bak-sessions-20260812 +228 -0
- package/src/stations/AddFileBar.tsx +59 -0
- package/src/stations/AudioCropStation.tsx +157 -0
- package/src/stations/ComfyPanel.tsx +210 -0
- package/src/stations/ComfyStation.tsx +196 -0
- package/src/stations/CondensedKnobStrip.tsx +245 -0
- package/src/stations/ConsolePanel.tsx +82 -0
- package/src/stations/FrameExtractStation.tsx +148 -0
- package/src/stations/GenIdentityBar.tsx +412 -0
- package/src/stations/GenerateStation.tsx +5603 -0
- package/src/stations/GenerateStation.tsx.bak-tester-removal +5170 -0
- package/src/stations/GoalComposer.tsx +139 -0
- package/src/stations/ImageCropStation.tsx +153 -0
- package/src/stations/KnobInfo.tsx +64 -0
- package/src/stations/PlaceholderStation.tsx +26 -0
- package/src/stations/SectionTabs.tsx +276 -0
- package/src/stations/SharePanel.tsx +298 -0
- package/src/stations/SidebarRegistry.tsx +105 -0
- package/src/stations/StationShell.tsx +96 -0
- package/src/stations/StudioClipsStation.tsx +28 -0
- package/src/stations/WorkbenchDrawer.tsx +66 -0
- package/src/stations/WorkbenchSidebar.tsx +1224 -0
- package/src/stations/WorkbenchStation.tsx +91 -0
- package/src/stations/comfyEmbed.ts +79 -0
- package/src/stations/drawerShell.tsx +419 -0
- package/src/stations/ops/AudioCropCore.tsx +218 -0
- package/src/stations/ops/CharacterGroupsPanel.tsx +716 -0
- package/src/stations/ops/FrameExtractCore.tsx +695 -0
- package/src/stations/ops/ImageCropCore.tsx +164 -0
- package/src/stations/ops/VideoCropCore.tsx +193 -0
- package/src/stations/ops/useVideoCropJobs.ts +224 -0
- package/src/stations/pollCaps.ts +87 -0
- package/src/stations/registry.ts +191 -0
- package/src/stations/studio/AngleRingEditor.tsx +159 -0
- package/src/stations/studio/IdentitiesStation.tsx +2455 -0
- package/src/stations/studio/IdentityProfileControls.tsx +262 -0
- package/src/stations/studio/StudioActiveProcesses.tsx +224 -0
- package/src/stations/studio/StudioAssistLogPanel.tsx +220 -0
- package/src/stations/studio/StudioGenerateMode.tsx +29 -0
- package/src/stations/studio/StudioGenerateTab.tsx +2688 -0
- package/src/stations/studio/StudioItemCard.tsx +334 -0
- package/src/stations/studio/StudioLibraryTab.tsx +506 -0
- package/src/stations/studio/StudioMovieComposer.tsx +2875 -0
- package/src/stations/studio/StudioMovieComposer.tsx.bak-sessions-20260812 +2729 -0
- package/src/stations/studio/StudioMovieSessions.tsx +553 -0
- package/src/stations/studio/StudioMovieSessions.tsx.bak-sessions-20260812 +417 -0
- package/src/stations/studio/StudioPlane.tsx +302 -0
- package/src/stations/studio/StudioSplicedRow.tsx +476 -0
- package/src/stations/studio/StudioViewer.tsx +339 -0
- package/src/stations/studio/TurntableViewer.tsx +220 -0
- package/src/stations/studio/movieTimeline.ts +198 -0
- package/src/stations/studio/promptCard.tsx +498 -0
- package/src/stations/studio/studioItem.ts +87 -0
- package/src/stations/studio/studioShared.tsx +821 -0
- package/src/stations/studio/useIdentityProfiles.ts +575 -0
- package/src/stations/studio/useReconstruction.ts +283 -0
- package/src/stations/types.ts +43 -0
- package/src/stations/useAudioCropJobs.ts +202 -0
- package/src/stations/useAudioExtractJob.ts +144 -0
- package/src/stations/useCropJobs.ts +206 -0
- package/src/stations/useFrameJobs.ts +140 -0
- package/src/stations/useGenerateJob.ts +151 -0
- package/src/stations/useGenerateMovieJob.ts +177 -0
- package/src/stations/useGenerateRowJobs.ts +184 -0
- package/src/stations/useGenerateSceneJob.ts +156 -0
- package/src/stations/useGenerateSceneRowJobs.ts +282 -0
- package/src/style/app.css +4974 -0
- package/src/transport/client.ts +262 -0
- package/src/transport/telemetry.ts +97 -0
- package/src/video/DropReceptacle.tsx +122 -0
- package/src/video/PromptAssistButtons.tsx +118 -0
- package/src/video/composerBridge.ts +66 -0
- package/src/video/contract.ts +599 -0
- package/src/video/jobTracker.ts +702 -0
- package/src/video/mediaLibrary.ts +328 -0
- package/src/video/renderCompat.ts +512 -0
- package/src/video/sessionForm.ts +189 -0
- package/src/video/spreadAssist.ts +392 -0
- package/src/video/studioBridge.ts +82 -0
- package/src/video/useMediaJobs.ts +279 -0
- package/src/video/useModels.ts +413 -0
- package/src/video/useMoviePresets.ts +97 -0
- package/src/video/useMovieSessions.ts +292 -0
- package/src/video/usePresets.ts +167 -0
- package/src/video/useProjects.ts +87 -0
- package/src/video/usePromptAssist.ts +394 -0
- package/src/video/useRenderPresets.ts +221 -0
- package/src/video/useStudioAssistLog.ts +266 -0
- package/src/video/useStudioPresets.ts +128 -0
- package/src/video/useWorkerCalls.ts +165 -0
- package/tsconfig.json +17 -0
- 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
|
+
}
|