@buildcores/render-client 1.1.0 → 1.3.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/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  var jsxRuntime = require('react/jsx-runtime');
4
- var React = require('react');
4
+ var react = require('react');
5
+ var framerMotion = require('framer-motion');
5
6
 
6
7
  // Helper to extract clientX from mouse or touch events
7
8
  const getClientX$1 = (e) => {
@@ -20,13 +21,13 @@ const calculateCircularFrame = (startFrame, deltaX, sensitivity, totalFrames) =>
20
21
  };
21
22
  const useSpriteScrubbing = (canvasRef, totalFrames, options = {}) => {
22
23
  const { mouseSensitivity = 0.1, touchSensitivity = 0.1, onFrameChange, } = options;
23
- const [isDragging, setIsDragging] = React.useState(false);
24
- const [dragStartX, setDragStartX] = React.useState(0);
25
- const [dragStartFrame, setDragStartFrame] = React.useState(0);
26
- const hasDragged = React.useRef(false);
27
- const currentFrame = React.useRef(0);
24
+ const [isDragging, setIsDragging] = react.useState(false);
25
+ const [dragStartX, setDragStartX] = react.useState(0);
26
+ const [dragStartFrame, setDragStartFrame] = react.useState(0);
27
+ const hasDragged = react.useRef(false);
28
+ const currentFrame = react.useRef(0);
28
29
  // Helper to start dragging (common logic for mouse and touch)
29
- const startDrag = React.useCallback((clientX, event) => {
30
+ const startDrag = react.useCallback((clientX, event) => {
30
31
  if (!canvasRef.current)
31
32
  return;
32
33
  setIsDragging(true);
@@ -36,7 +37,7 @@ const useSpriteScrubbing = (canvasRef, totalFrames, options = {}) => {
36
37
  event.preventDefault();
37
38
  }, [canvasRef]);
38
39
  // Helper to handle drag movement (common logic for mouse and touch)
39
- const handleDragMove = React.useCallback((clientX, sensitivity) => {
40
+ const handleDragMove = react.useCallback((clientX, sensitivity) => {
40
41
  if (!isDragging || !canvasRef.current)
41
42
  return;
42
43
  const deltaX = clientX - dragStartX;
@@ -50,29 +51,29 @@ const useSpriteScrubbing = (canvasRef, totalFrames, options = {}) => {
50
51
  return newFrame;
51
52
  }, [isDragging, dragStartX, dragStartFrame, totalFrames, onFrameChange]);
52
53
  // Helper to end dragging (common logic for mouse and touch)
53
- const endDrag = React.useCallback(() => {
54
+ const endDrag = react.useCallback(() => {
54
55
  setIsDragging(false);
55
56
  }, []);
56
- const handleMouseDown = React.useCallback((e) => {
57
+ const handleMouseDown = react.useCallback((e) => {
57
58
  startDrag(e.clientX, e.nativeEvent);
58
59
  }, [startDrag]);
59
- const handleTouchStart = React.useCallback((e) => {
60
+ const handleTouchStart = react.useCallback((e) => {
60
61
  startDrag(e.touches[0].clientX, e.nativeEvent);
61
62
  }, [startDrag]);
62
- const handleDocumentMouseMove = React.useCallback((e) => {
63
+ const handleDocumentMouseMove = react.useCallback((e) => {
63
64
  return handleDragMove(getClientX$1(e), mouseSensitivity);
64
65
  }, [handleDragMove, mouseSensitivity]);
65
- const handleDocumentTouchMove = React.useCallback((e) => {
66
+ const handleDocumentTouchMove = react.useCallback((e) => {
66
67
  return handleDragMove(getClientX$1(e), touchSensitivity);
67
68
  }, [handleDragMove, touchSensitivity]);
68
- const handleDocumentMouseUp = React.useCallback(() => {
69
+ const handleDocumentMouseUp = react.useCallback(() => {
69
70
  endDrag();
70
71
  }, [endDrag]);
71
- const handleDocumentTouchEnd = React.useCallback(() => {
72
+ const handleDocumentTouchEnd = react.useCallback(() => {
72
73
  endDrag();
73
74
  }, [endDrag]);
74
75
  // Add document-level event listeners when dragging starts
75
- React.useEffect(() => {
76
+ react.useEffect(() => {
76
77
  if (isDragging) {
77
78
  document.addEventListener("mousemove", handleDocumentMouseMove);
78
79
  document.addEventListener("mouseup", handleDocumentMouseUp);
@@ -104,191 +105,11 @@ const useSpriteScrubbing = (canvasRef, totalFrames, options = {}) => {
104
105
  };
105
106
  };
106
107
 
107
- /**
108
- * @public
109
- */
110
- const MotionConfigContext = React.createContext({
111
- transformPagePoint: (p) => p,
112
- isStatic: false,
113
- reducedMotion: "never",
114
- });
115
-
116
- /*#__NO_SIDE_EFFECTS__*/
117
- const noop = (any) => any;
118
-
119
- if (process.env.NODE_ENV !== "production") ;
120
-
121
- function createRenderStep(runNextFrame) {
122
- /**
123
- * We create and reuse two queues, one to queue jobs for the current frame
124
- * and one for the next. We reuse to avoid triggering GC after x frames.
125
- */
126
- let thisFrame = new Set();
127
- let nextFrame = new Set();
128
- /**
129
- * Track whether we're currently processing jobs in this step. This way
130
- * we can decide whether to schedule new jobs for this frame or next.
131
- */
132
- let isProcessing = false;
133
- let flushNextFrame = false;
134
- /**
135
- * A set of processes which were marked keepAlive when scheduled.
136
- */
137
- const toKeepAlive = new WeakSet();
138
- let latestFrameData = {
139
- delta: 0.0,
140
- timestamp: 0.0,
141
- isProcessing: false,
142
- };
143
- function triggerCallback(callback) {
144
- if (toKeepAlive.has(callback)) {
145
- step.schedule(callback);
146
- runNextFrame();
147
- }
148
- callback(latestFrameData);
149
- }
150
- const step = {
151
- /**
152
- * Schedule a process to run on the next frame.
153
- */
154
- schedule: (callback, keepAlive = false, immediate = false) => {
155
- const addToCurrentFrame = immediate && isProcessing;
156
- const queue = addToCurrentFrame ? thisFrame : nextFrame;
157
- if (keepAlive)
158
- toKeepAlive.add(callback);
159
- if (!queue.has(callback))
160
- queue.add(callback);
161
- return callback;
162
- },
163
- /**
164
- * Cancel the provided callback from running on the next frame.
165
- */
166
- cancel: (callback) => {
167
- nextFrame.delete(callback);
168
- toKeepAlive.delete(callback);
169
- },
170
- /**
171
- * Execute all schedule callbacks.
172
- */
173
- process: (frameData) => {
174
- latestFrameData = frameData;
175
- /**
176
- * If we're already processing we've probably been triggered by a flushSync
177
- * inside an existing process. Instead of executing, mark flushNextFrame
178
- * as true and ensure we flush the following frame at the end of this one.
179
- */
180
- if (isProcessing) {
181
- flushNextFrame = true;
182
- return;
183
- }
184
- isProcessing = true;
185
- [thisFrame, nextFrame] = [nextFrame, thisFrame];
186
- // Execute this frame
187
- thisFrame.forEach(triggerCallback);
188
- // Clear the frame so no callbacks remain. This is to avoid
189
- // memory leaks should this render step not run for a while.
190
- thisFrame.clear();
191
- isProcessing = false;
192
- if (flushNextFrame) {
193
- flushNextFrame = false;
194
- step.process(frameData);
195
- }
196
- },
197
- };
198
- return step;
199
- }
200
-
201
- const stepsOrder = [
202
- "read", // Read
203
- "resolveKeyframes", // Write/Read/Write/Read
204
- "update", // Compute
205
- "preRender", // Compute
206
- "render", // Write
207
- "postRender", // Compute
208
- ];
209
- const maxElapsed = 40;
210
- function createRenderBatcher(scheduleNextBatch, allowKeepAlive) {
211
- let runNextFrame = false;
212
- let useDefaultElapsed = true;
213
- const state = {
214
- delta: 0.0,
215
- timestamp: 0.0,
216
- isProcessing: false,
217
- };
218
- const flagRunNextFrame = () => (runNextFrame = true);
219
- const steps = stepsOrder.reduce((acc, key) => {
220
- acc[key] = createRenderStep(flagRunNextFrame);
221
- return acc;
222
- }, {});
223
- const { read, resolveKeyframes, update, preRender, render, postRender } = steps;
224
- const processBatch = () => {
225
- const timestamp = performance.now();
226
- runNextFrame = false;
227
- state.delta = useDefaultElapsed
228
- ? 1000 / 60
229
- : Math.max(Math.min(timestamp - state.timestamp, maxElapsed), 1);
230
- state.timestamp = timestamp;
231
- state.isProcessing = true;
232
- // Unrolled render loop for better per-frame performance
233
- read.process(state);
234
- resolveKeyframes.process(state);
235
- update.process(state);
236
- preRender.process(state);
237
- render.process(state);
238
- postRender.process(state);
239
- state.isProcessing = false;
240
- if (runNextFrame && allowKeepAlive) {
241
- useDefaultElapsed = false;
242
- scheduleNextBatch(processBatch);
243
- }
244
- };
245
- const wake = () => {
246
- runNextFrame = true;
247
- useDefaultElapsed = true;
248
- if (!state.isProcessing) {
249
- scheduleNextBatch(processBatch);
250
- }
251
- };
252
- const schedule = stepsOrder.reduce((acc, key) => {
253
- const step = steps[key];
254
- acc[key] = (process, keepAlive = false, immediate = false) => {
255
- if (!runNextFrame)
256
- wake();
257
- return step.schedule(process, keepAlive, immediate);
258
- };
259
- return acc;
260
- }, {});
261
- const cancel = (process) => {
262
- for (let i = 0; i < stepsOrder.length; i++) {
263
- steps[stepsOrder[i]].cancel(process);
264
- }
265
- };
266
- return { schedule, cancel, state, steps };
267
- }
268
-
269
- const { schedule: frame, cancel: cancelFrame} = createRenderBatcher(typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame : noop, true);
270
-
271
- function useAnimationFrame(callback) {
272
- const initialTimestamp = React.useRef(0);
273
- const { isStatic } = React.useContext(MotionConfigContext);
274
- React.useEffect(() => {
275
- if (isStatic)
276
- return;
277
- const provideTimeSinceStart = ({ timestamp, delta }) => {
278
- if (!initialTimestamp.current)
279
- initialTimestamp.current = timestamp;
280
- callback(timestamp - initialTimestamp.current, delta);
281
- };
282
- frame.update(provideTimeSinceStart, true);
283
- return () => cancelFrame(provideTimeSinceStart);
284
- }, [callback]);
285
- }
286
-
287
108
  function useBouncePatternProgress(enabled = true) {
288
- const [value, setValue] = React.useState(0);
289
- const [isBouncing, setIsBouncing] = React.useState(false);
290
- const start = React.useRef(null);
291
- useAnimationFrame((t) => {
109
+ const [value, setValue] = react.useState(0);
110
+ const [isBouncing, setIsBouncing] = react.useState(false);
111
+ const start = react.useRef(null);
112
+ framerMotion.useAnimationFrame((t) => {
292
113
  if (!enabled) {
293
114
  // Reset animation when disabled
294
115
  if (start.current !== null) {
@@ -355,6 +176,11 @@ const renderBuildExperimental = async (request, config) => {
355
176
  const requestWithFormat = {
356
177
  ...request,
357
178
  format: request.format || "video", // Default to video format
179
+ // Include width and height if provided
180
+ ...(request.width !== undefined ? { width: request.width } : {}),
181
+ ...(request.height !== undefined ? { height: request.height } : {}),
182
+ // Include profile if provided
183
+ ...(request.profile ? { profile: request.profile } : {}),
358
184
  };
359
185
  const response = await fetch(buildApiUrl(API_ENDPOINTS.RENDER_BUILD_EXPERIMENTAL, config), {
360
186
  method: "POST",
@@ -379,6 +205,11 @@ const createRenderBuildJob = async (request, config) => {
379
205
  parts: request.parts,
380
206
  // If provided, forward format; default handled server-side but we keep explicit default
381
207
  ...(request.format ? { format: request.format } : {}),
208
+ // Include width and height if provided
209
+ ...(request.width !== undefined ? { width: request.width } : {}),
210
+ ...(request.height !== undefined ? { height: request.height } : {}),
211
+ // Include profile if provided
212
+ ...(request.profile ? { profile: request.profile } : {}),
382
213
  };
383
214
  const response = await fetch(buildApiUrl(API_ENDPOINTS.RENDER_BUILD, config), {
384
215
  method: "POST",
@@ -439,6 +270,11 @@ const renderSpriteExperimental = async (request, config) => {
439
270
  const requestWithFormat = {
440
271
  ...request,
441
272
  format: "sprite",
273
+ // Include width and height if provided
274
+ ...(request.width !== undefined ? { width: request.width } : {}),
275
+ ...(request.height !== undefined ? { height: request.height } : {}),
276
+ // Include profile if provided
277
+ ...(request.profile ? { profile: request.profile } : {}),
442
278
  };
443
279
  const response = await fetch(buildApiUrl(API_ENDPOINTS.RENDER_BUILD_EXPERIMENTAL, config), {
444
280
  method: "POST",
@@ -549,11 +385,11 @@ const arePartsEqual = (parts1, parts2) => {
549
385
  return true;
550
386
  };
551
387
  const useBuildRender = (parts, apiConfig, onLoadStart, options) => {
552
- const [videoSrc, setVideoSrc] = React.useState(null);
553
- const [isRenderingBuild, setIsRenderingBuild] = React.useState(false);
554
- const [renderError, setRenderError] = React.useState(null);
555
- const previousPartsRef = React.useRef(null);
556
- const fetchRenderBuild = React.useCallback(async (currentParts) => {
388
+ const [videoSrc, setVideoSrc] = react.useState(null);
389
+ const [isRenderingBuild, setIsRenderingBuild] = react.useState(false);
390
+ const [renderError, setRenderError] = react.useState(null);
391
+ const previousPartsRef = react.useRef(null);
392
+ const fetchRenderBuild = react.useCallback(async (currentParts) => {
557
393
  try {
558
394
  setIsRenderingBuild(true);
559
395
  setRenderError(null);
@@ -588,7 +424,7 @@ const useBuildRender = (parts, apiConfig, onLoadStart, options) => {
588
424
  }
589
425
  }, [apiConfig, onLoadStart, options?.mode]);
590
426
  // Effect to call API when parts content changes (using custom equality check)
591
- React.useEffect(() => {
427
+ react.useEffect(() => {
592
428
  const shouldFetch = previousPartsRef.current === null ||
593
429
  !arePartsEqual(previousPartsRef.current, parts);
594
430
  if (shouldFetch) {
@@ -597,7 +433,7 @@ const useBuildRender = (parts, apiConfig, onLoadStart, options) => {
597
433
  }
598
434
  }, [parts, fetchRenderBuild]);
599
435
  // Cleanup effect for component unmount
600
- React.useEffect(() => {
436
+ react.useEffect(() => {
601
437
  return () => {
602
438
  if (videoSrc && videoSrc.startsWith("blob:")) {
603
439
  URL.revokeObjectURL(videoSrc);
@@ -612,12 +448,12 @@ const useBuildRender = (parts, apiConfig, onLoadStart, options) => {
612
448
  };
613
449
 
614
450
  const useSpriteRender = (parts, apiConfig, onLoadStart, options) => {
615
- const [spriteSrc, setSpriteSrc] = React.useState(null);
616
- const [isRenderingSprite, setIsRenderingSprite] = React.useState(false);
617
- const [renderError, setRenderError] = React.useState(null);
618
- const [spriteMetadata, setSpriteMetadata] = React.useState(null);
619
- const previousPartsRef = React.useRef(null);
620
- const fetchRenderSprite = React.useCallback(async (currentParts) => {
451
+ const [spriteSrc, setSpriteSrc] = react.useState(null);
452
+ const [isRenderingSprite, setIsRenderingSprite] = react.useState(false);
453
+ const [renderError, setRenderError] = react.useState(null);
454
+ const [spriteMetadata, setSpriteMetadata] = react.useState(null);
455
+ const previousPartsRef = react.useRef(null);
456
+ const fetchRenderSprite = react.useCallback(async (currentParts) => {
621
457
  try {
622
458
  setIsRenderingSprite(true);
623
459
  setRenderError(null);
@@ -661,7 +497,7 @@ const useSpriteRender = (parts, apiConfig, onLoadStart, options) => {
661
497
  }
662
498
  }, [apiConfig, onLoadStart, options?.mode]);
663
499
  // Effect to call API when parts content changes (using custom equality check)
664
- React.useEffect(() => {
500
+ react.useEffect(() => {
665
501
  const shouldFetch = previousPartsRef.current === null ||
666
502
  !arePartsEqual(previousPartsRef.current, parts);
667
503
  if (shouldFetch) {
@@ -670,7 +506,7 @@ const useSpriteRender = (parts, apiConfig, onLoadStart, options) => {
670
506
  }
671
507
  }, [parts, fetchRenderSprite]);
672
508
  // Cleanup effect for component unmount
673
- React.useEffect(() => {
509
+ react.useEffect(() => {
674
510
  return () => {
675
511
  if (spriteSrc && spriteSrc.startsWith("blob:")) {
676
512
  URL.revokeObjectURL(spriteSrc);
@@ -740,11 +576,104 @@ const InstructionTooltip = ({ isVisible, progressValue, instructionIcon, }) => {
740
576
  } })) }));
741
577
  };
742
578
 
579
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
580
+ const getTouchDistance = (touches) => {
581
+ const first = touches[0];
582
+ const second = touches[1];
583
+ if (!first || !second)
584
+ return 0;
585
+ return Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY);
586
+ };
587
+ const useZoomPan = ({ displayWidth, displayHeight, minScale = 1, maxScale = 4, } = {}) => {
588
+ const [scale, setScale] = react.useState(1);
589
+ const [isPinching, setIsPinching] = react.useState(false);
590
+ const scaleRef = react.useRef(1);
591
+ const pinchDataRef = react.useRef({
592
+ initialDistance: 0,
593
+ initialScale: 1,
594
+ });
595
+ const setScaleSafe = react.useCallback((next) => {
596
+ const clamped = clamp(next, minScale, maxScale);
597
+ if (clamped === scaleRef.current)
598
+ return;
599
+ scaleRef.current = clamped;
600
+ setScale(clamped);
601
+ }, [minScale, maxScale]);
602
+ const handleWheel = react.useCallback((event) => {
603
+ event.preventDefault();
604
+ event.stopPropagation();
605
+ const deltaY = event.deltaMode === 1
606
+ ? event.deltaY * 16
607
+ : event.deltaMode === 2
608
+ ? event.deltaY * (displayHeight ?? 300)
609
+ : event.deltaY;
610
+ const zoomFactor = Math.exp(-deltaY * 0.0015);
611
+ const nextScale = scaleRef.current * zoomFactor;
612
+ setScaleSafe(nextScale);
613
+ }, [setScaleSafe, displayHeight]);
614
+ const handleTouchStart = react.useCallback((event) => {
615
+ if (event.touches.length < 2) {
616
+ return false;
617
+ }
618
+ const distance = getTouchDistance(event.touches);
619
+ if (!distance) {
620
+ return false;
621
+ }
622
+ pinchDataRef.current = {
623
+ initialDistance: distance,
624
+ initialScale: scaleRef.current,
625
+ };
626
+ setIsPinching(true);
627
+ event.preventDefault();
628
+ return true;
629
+ }, []);
630
+ react.useEffect(() => {
631
+ if (!isPinching)
632
+ return;
633
+ const handleMove = (event) => {
634
+ if (event.touches.length < 2)
635
+ return;
636
+ const distance = getTouchDistance(event.touches);
637
+ if (!distance || pinchDataRef.current.initialDistance === 0)
638
+ return;
639
+ const scaleFactor = distance / pinchDataRef.current.initialDistance;
640
+ const nextScale = pinchDataRef.current.initialScale * scaleFactor;
641
+ setScaleSafe(nextScale);
642
+ event.preventDefault();
643
+ };
644
+ const handleEnd = (event) => {
645
+ if (event.touches.length < 2) {
646
+ setIsPinching(false);
647
+ }
648
+ };
649
+ window.addEventListener("touchmove", handleMove, { passive: false });
650
+ window.addEventListener("touchend", handleEnd);
651
+ window.addEventListener("touchcancel", handleEnd);
652
+ return () => {
653
+ window.removeEventListener("touchmove", handleMove);
654
+ window.removeEventListener("touchend", handleEnd);
655
+ window.removeEventListener("touchcancel", handleEnd);
656
+ };
657
+ }, [isPinching, setScaleSafe]);
658
+ const reset = react.useCallback(() => {
659
+ scaleRef.current = 1;
660
+ setScale(1);
661
+ }, []);
662
+ return {
663
+ scale,
664
+ isPinching,
665
+ handleWheel,
666
+ handleTouchStart,
667
+ reset,
668
+ };
669
+ };
670
+
743
671
  const BuildRender = ({ parts, width, height, size, apiConfig, useSpriteRenderOptions, mouseSensitivity = 0.2, touchSensitivity = 0.2, }) => {
744
- const canvasRef = React.useRef(null);
745
- const [img, setImg] = React.useState(null);
746
- const [isLoading, setIsLoading] = React.useState(true);
747
- const [bouncingAllowed, setBouncingAllowed] = React.useState(false);
672
+ const canvasRef = react.useRef(null);
673
+ const containerRef = react.useRef(null);
674
+ const [img, setImg] = react.useState(null);
675
+ const [isLoading, setIsLoading] = react.useState(true);
676
+ const [bouncingAllowed, setBouncingAllowed] = react.useState(false);
748
677
  const displayW = width ?? size ?? 300;
749
678
  const displayH = height ?? size ?? 300;
750
679
  // Use custom hook for sprite rendering
@@ -753,12 +682,16 @@ const BuildRender = ({ parts, width, height, size, apiConfig, useSpriteRenderOpt
753
682
  const total = spriteMetadata ? spriteMetadata.totalFrames : 72;
754
683
  const cols = spriteMetadata ? spriteMetadata.cols : 12;
755
684
  const rows = spriteMetadata ? spriteMetadata.rows : 6;
756
- const frameRef = React.useRef(0);
685
+ const frameRef = react.useRef(0);
686
+ const { scale, handleWheel: handleZoomWheel, handleTouchStart: handleZoomTouchStart, reset: resetZoom, } = useZoomPan({
687
+ displayWidth: displayW,
688
+ displayHeight: displayH,
689
+ });
757
690
  // Image/frame sizes
758
691
  const frameW = img ? img.width / cols : 0;
759
692
  const frameH = img ? img.height / rows : 0;
760
693
  // ---- Load sprite image ----
761
- React.useEffect(() => {
694
+ react.useEffect(() => {
762
695
  if (!spriteSrc) {
763
696
  setImg(null);
764
697
  setIsLoading(true);
@@ -783,7 +716,7 @@ const BuildRender = ({ parts, width, height, size, apiConfig, useSpriteRenderOpt
783
716
  };
784
717
  }, [spriteSrc]);
785
718
  // ---- Drawing function ----
786
- const draw = React.useCallback((frameIndex) => {
719
+ const draw = react.useCallback((frameIndex) => {
787
720
  const cnv = canvasRef.current;
788
721
  if (!cnv || !img || !frameW || !frameH)
789
722
  return;
@@ -812,8 +745,12 @@ const BuildRender = ({ parts, width, height, size, apiConfig, useSpriteRenderOpt
812
745
  ctx.clearRect(0, 0, targetW, targetH);
813
746
  ctx.imageSmoothingEnabled = true;
814
747
  ctx.imageSmoothingQuality = "high";
815
- ctx.drawImage(img, sx, sy, sw, sh, 0, 0, targetW, targetH);
816
- }, [img, frameW, frameH, displayW, displayH, cols, total]);
748
+ const scaledW = targetW * scale;
749
+ const scaledH = targetH * scale;
750
+ const offsetX = -((scaledW - targetW) / 2);
751
+ const offsetY = -((scaledH - targetH) / 2);
752
+ ctx.drawImage(img, sx, sy, sw, sh, offsetX, offsetY, scaledW, scaledH);
753
+ }, [img, frameW, frameH, displayW, displayH, cols, total, scale]);
817
754
  const { isDragging, handleMouseDown, handleTouchStart, hasDragged } = useSpriteScrubbing(canvasRef, total, {
818
755
  mouseSensitivity,
819
756
  touchSensitivity,
@@ -822,12 +759,12 @@ const BuildRender = ({ parts, width, height, size, apiConfig, useSpriteRenderOpt
822
759
  draw(newFrame);
823
760
  },
824
761
  });
825
- React.useCallback(() => {
762
+ react.useCallback(() => {
826
763
  setIsLoading(true);
827
764
  setBouncingAllowed(false);
828
765
  }, []);
829
766
  // Auto-rotate when bouncing is allowed and not dragged
830
- React.useEffect(() => {
767
+ react.useEffect(() => {
831
768
  if (hasDragged.current || !img)
832
769
  return;
833
770
  // Calculate frame based on progress value (similar to video time calculation)
@@ -835,18 +772,66 @@ const BuildRender = ({ parts, width, height, size, apiConfig, useSpriteRenderOpt
835
772
  frameRef.current = frame;
836
773
  draw(frame);
837
774
  }, [progressValue, hasDragged, img, total, draw]);
838
- // Initial draw once image is ready
839
- React.useEffect(() => {
775
+ // Reset zoom when sprite changes or container size updates
776
+ react.useEffect(() => {
777
+ resetZoom();
778
+ }, [spriteSrc, displayW, displayH, resetZoom]);
779
+ // Add native wheel event listener to prevent scrolling AND handle zoom
780
+ react.useEffect(() => {
781
+ const container = containerRef.current;
782
+ if (!container)
783
+ return;
784
+ const handleNativeWheel = (event) => {
785
+ event.preventDefault();
786
+ event.stopPropagation();
787
+ // Manually trigger zoom since we're preventing the React event
788
+ event.deltaMode === 1
789
+ ? event.deltaY * 16
790
+ : event.deltaMode === 2
791
+ ? event.deltaY * (displayH ?? 300)
792
+ : event.deltaY;
793
+ // We need to call the zoom handler directly
794
+ // Create a synthetic React event-like object
795
+ const syntheticEvent = {
796
+ preventDefault: () => { },
797
+ stopPropagation: () => { },
798
+ deltaY: event.deltaY,
799
+ deltaMode: event.deltaMode,
800
+ currentTarget: container,
801
+ };
802
+ handleZoomWheel(syntheticEvent);
803
+ hasDragged.current = true;
804
+ };
805
+ // Add listener to container to catch all wheel events
806
+ container.addEventListener('wheel', handleNativeWheel, { passive: false });
807
+ return () => {
808
+ container.removeEventListener('wheel', handleNativeWheel);
809
+ };
810
+ }, [handleZoomWheel, scale, displayH]);
811
+ // Initial draw once image is ready or zoom changes
812
+ react.useEffect(() => {
840
813
  if (img && !isLoading) {
841
814
  draw(frameRef.current);
842
815
  }
843
816
  }, [img, isLoading, draw]);
844
- return (jsxRuntime.jsxs("div", { style: {
817
+ const handleCanvasTouchStart = react.useCallback((event) => {
818
+ if (handleZoomTouchStart(event)) {
819
+ hasDragged.current = true;
820
+ return;
821
+ }
822
+ handleTouchStart(event);
823
+ }, [handleZoomTouchStart, handleTouchStart, hasDragged]);
824
+ react.useCallback((event) => {
825
+ hasDragged.current = true;
826
+ handleZoomWheel(event);
827
+ }, [handleZoomWheel, hasDragged]);
828
+ return (jsxRuntime.jsxs("div", { ref: containerRef, style: {
845
829
  position: "relative",
846
830
  width: displayW,
847
831
  height: displayH,
848
832
  backgroundColor: "black",
849
- }, children: [img && (jsxRuntime.jsx("canvas", { ref: canvasRef, onMouseDown: handleMouseDown, onTouchStart: handleTouchStart, style: {
833
+ overflow: "hidden",
834
+ }, children: [img && (jsxRuntime.jsx("canvas", { ref: canvasRef, onMouseDown: handleMouseDown, onTouchStart: handleCanvasTouchStart, style: {
850
835
  width: displayW,
851
836
  height: displayH,
852
837
  cursor: isDragging ? "grabbing" : "grab",
@@ -879,12 +864,12 @@ const calculateCircularTime = (startTime, deltaX, sensitivity, duration) => {
879
864
  };
880
865
  const useVideoScrubbing = (videoRef, options = {}) => {
881
866
  const { mouseSensitivity = 0.01, touchSensitivity = 0.01 } = options;
882
- const [isDragging, setIsDragging] = React.useState(false);
883
- const [dragStartX, setDragStartX] = React.useState(0);
884
- const [dragStartTime, setDragStartTime] = React.useState(0);
885
- const hasDragged = React.useRef(false);
867
+ const [isDragging, setIsDragging] = react.useState(false);
868
+ const [dragStartX, setDragStartX] = react.useState(0);
869
+ const [dragStartTime, setDragStartTime] = react.useState(0);
870
+ const hasDragged = react.useRef(false);
886
871
  // Helper to start dragging (common logic for mouse and touch)
887
- const startDrag = React.useCallback((clientX, event) => {
872
+ const startDrag = react.useCallback((clientX, event) => {
888
873
  if (!videoRef.current)
889
874
  return;
890
875
  setIsDragging(true);
@@ -894,7 +879,7 @@ const useVideoScrubbing = (videoRef, options = {}) => {
894
879
  event.preventDefault();
895
880
  }, [videoRef]);
896
881
  // Helper to handle drag movement (common logic for mouse and touch)
897
- const handleDragMove = React.useCallback((clientX, sensitivity) => {
882
+ const handleDragMove = react.useCallback((clientX, sensitivity) => {
898
883
  if (!isDragging || !videoRef.current)
899
884
  return;
900
885
  const deltaX = clientX - dragStartX;
@@ -905,29 +890,29 @@ const useVideoScrubbing = (videoRef, options = {}) => {
905
890
  }
906
891
  }, [isDragging, dragStartX, dragStartTime, videoRef]);
907
892
  // Helper to end dragging (common logic for mouse and touch)
908
- const endDrag = React.useCallback(() => {
893
+ const endDrag = react.useCallback(() => {
909
894
  setIsDragging(false);
910
895
  }, []);
911
- const handleMouseDown = React.useCallback((e) => {
896
+ const handleMouseDown = react.useCallback((e) => {
912
897
  startDrag(e.clientX, e.nativeEvent);
913
898
  }, [startDrag]);
914
- const handleTouchStart = React.useCallback((e) => {
899
+ const handleTouchStart = react.useCallback((e) => {
915
900
  startDrag(e.touches[0].clientX, e.nativeEvent);
916
901
  }, [startDrag]);
917
- const handleDocumentMouseMove = React.useCallback((e) => {
902
+ const handleDocumentMouseMove = react.useCallback((e) => {
918
903
  handleDragMove(getClientX(e), mouseSensitivity);
919
904
  }, [handleDragMove, mouseSensitivity]);
920
- const handleDocumentTouchMove = React.useCallback((e) => {
905
+ const handleDocumentTouchMove = react.useCallback((e) => {
921
906
  handleDragMove(getClientX(e), touchSensitivity);
922
907
  }, [handleDragMove, touchSensitivity]);
923
- const handleDocumentMouseUp = React.useCallback(() => {
908
+ const handleDocumentMouseUp = react.useCallback(() => {
924
909
  endDrag();
925
910
  }, [endDrag]);
926
- const handleDocumentTouchEnd = React.useCallback(() => {
911
+ const handleDocumentTouchEnd = react.useCallback(() => {
927
912
  endDrag();
928
913
  }, [endDrag]);
929
914
  // Add document-level event listeners when dragging starts
930
- React.useEffect(() => {
915
+ react.useEffect(() => {
931
916
  if (isDragging) {
932
917
  document.addEventListener("mousemove", handleDocumentMouseMove);
933
918
  document.addEventListener("mouseup", handleDocumentMouseUp);
@@ -956,9 +941,9 @@ const useVideoScrubbing = (videoRef, options = {}) => {
956
941
  };
957
942
 
958
943
  const BuildRenderVideo = ({ parts, width, height, size, apiConfig, useBuildRenderOptions, mouseSensitivity = 0.01, touchSensitivity = 0.01, }) => {
959
- const videoRef = React.useRef(null);
960
- const [isLoading, setIsLoading] = React.useState(true);
961
- const [bouncingAllowed, setBouncingAllowed] = React.useState(false);
944
+ const videoRef = react.useRef(null);
945
+ const [isLoading, setIsLoading] = react.useState(true);
946
+ const [bouncingAllowed, setBouncingAllowed] = react.useState(false);
962
947
  const displayW = width ?? size ?? 300;
963
948
  const displayH = height ?? size ?? 300;
964
949
  // Use custom hook for build rendering
@@ -968,18 +953,18 @@ const BuildRenderVideo = ({ parts, width, height, size, apiConfig, useBuildRende
968
953
  mouseSensitivity,
969
954
  touchSensitivity,
970
955
  });
971
- const handleLoadStartInternal = React.useCallback(() => {
956
+ const handleLoadStartInternal = react.useCallback(() => {
972
957
  setIsLoading(true);
973
958
  setBouncingAllowed(false);
974
959
  }, []);
975
- const handleCanPlayInternal = React.useCallback(() => {
960
+ const handleCanPlayInternal = react.useCallback(() => {
976
961
  setIsLoading(false);
977
962
  // Start bouncing animation after delay
978
963
  setTimeout(() => {
979
964
  setBouncingAllowed(true);
980
965
  }, 2000);
981
966
  }, []);
982
- React.useEffect(() => {
967
+ react.useEffect(() => {
983
968
  if (hasDragged.current || !videoRef.current)
984
969
  return;
985
970
  const duration = videoRef.current.duration;