@buildcores/render-client 1.4.1 → 1.5.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.
@@ -0,0 +1,17 @@
1
+ export interface ContinuousSpinResult {
2
+ /** Current frame index (integer part) */
3
+ frame: number;
4
+ /** Next frame index for interpolation */
5
+ nextFrame: number;
6
+ /** Blend factor between frames (0-1), used for cross-fade interpolation */
7
+ blend: number;
8
+ }
9
+ /**
10
+ * Hook for continuous 360° spin animation with smooth interpolation support.
11
+ *
12
+ * @param enabled - Whether the animation is enabled
13
+ * @param duration - Duration in ms for one full rotation (default: 10000ms = 10 seconds)
14
+ * @param totalFrames - Total number of frames in the sprite (default: 72)
15
+ * @returns Object with current frame, next frame, and blend factor for smooth interpolation
16
+ */
17
+ export declare function useContinuousSpin(enabled?: boolean, duration?: number, totalFrames?: number): ContinuousSpinResult;
@@ -36,6 +36,7 @@ export type SpriteRenderInput = {
36
36
  showGrid?: boolean;
37
37
  cameraOffsetX?: number;
38
38
  gridSettings?: RenderGridSettings;
39
+ frameQuality?: 'standard' | 'high';
39
40
  } | {
40
41
  type: 'shareCode';
41
42
  shareCode: string;
@@ -43,5 +44,6 @@ export type SpriteRenderInput = {
43
44
  showGrid?: boolean;
44
45
  cameraOffsetX?: number;
45
46
  gridSettings?: RenderGridSettings;
47
+ frameQuality?: 'standard' | 'high';
46
48
  };
47
49
  export declare const useSpriteRender: (input: RenderBuildRequest | SpriteRenderInput, apiConfig: ApiConfig, onLoadStart?: () => void, options?: UseSpriteRenderOptions) => UseSpriteRenderReturn;
package/dist/index.d.ts CHANGED
@@ -240,6 +240,81 @@ interface BuildRenderProps {
240
240
  * Works for both parts and shareCode rendering.
241
241
  */
242
242
  gridSettings?: GridSettings;
243
+ /**
244
+ * Animation mode for the auto-rotation.
245
+ *
246
+ * - **bounce**: (default) Quick back-and-forth partial rotation with pauses
247
+ * - **spin360**: Slow continuous 360° rotation
248
+ *
249
+ * @example
250
+ * ```tsx
251
+ * // Continuous slow spin (good for showcases)
252
+ * <BuildRender
253
+ * shareCode="abc123"
254
+ * animationMode="spin360"
255
+ * spinDuration={12000} // 12 seconds per full rotation
256
+ * />
257
+ * ```
258
+ *
259
+ * @default "bounce"
260
+ */
261
+ animationMode?: 'bounce' | 'spin360';
262
+ /**
263
+ * Duration in milliseconds for one full 360° rotation.
264
+ * Only applies when `animationMode` is "spin360".
265
+ *
266
+ * @example
267
+ * ```tsx
268
+ * <BuildRender
269
+ * shareCode="abc123"
270
+ * animationMode="spin360"
271
+ * spinDuration={15000} // 15 seconds per rotation
272
+ * />
273
+ * ```
274
+ *
275
+ * @default 10000 (10 seconds)
276
+ */
277
+ spinDuration?: number;
278
+ /**
279
+ * Whether to enable user interaction (drag to rotate, scroll to zoom).
280
+ *
281
+ * When set to `false`:
282
+ * - Drag-to-rotate is disabled
283
+ * - Scroll-to-zoom is disabled
284
+ * - Cursor shows as "pointer" instead of "grab"
285
+ * - Click events pass through (useful for wrapping in a link)
286
+ *
287
+ * @example
288
+ * ```tsx
289
+ * // Non-interactive showcase with link
290
+ * <a href="https://buildcores.com/build/abc123">
291
+ * <BuildRender
292
+ * shareCode="abc123"
293
+ * animationMode="spin360"
294
+ * interactive={false}
295
+ * />
296
+ * </a>
297
+ * ```
298
+ *
299
+ * @default true
300
+ */
301
+ interactive?: boolean;
302
+ /**
303
+ * Frame quality for sprite renders.
304
+ * - **standard**: 72 frames (default) - good balance of quality and file size
305
+ * - **high**: 144 frames - smoother animation, larger file size (~2x file size)
306
+ *
307
+ * @example
308
+ * ```tsx
309
+ * <BuildRender
310
+ * shareCode="abc123"
311
+ * frameQuality="high" // 144 frames for smoother rotation
312
+ * />
313
+ * ```
314
+ *
315
+ * @default "standard"
316
+ */
317
+ frameQuality?: 'standard' | 'high';
243
318
  }
244
319
  /**
245
320
  * API configuration for environment and authentication
@@ -448,6 +523,14 @@ interface RenderBuildRequest {
448
523
  * Only applies when showGrid is true.
449
524
  */
450
525
  gridSettings?: GridSettings;
526
+ /**
527
+ * Frame quality for sprite renders.
528
+ * - **standard**: 72 frames (default) - good balance of quality and file size
529
+ * - **high**: 144 frames - smoother animation, larger file size
530
+ *
531
+ * @default "standard"
532
+ */
533
+ frameQuality?: 'standard' | 'high';
451
534
  }
452
535
  /**
453
536
  * Response structure containing all available parts for each category.
@@ -640,6 +723,8 @@ interface RenderByShareCodeOptions {
640
723
  cameraOffsetX?: number;
641
724
  /** Grid appearance settings (for thicker/more visible grid in renders) */
642
725
  gridSettings?: GridSettings;
726
+ /** Frame quality - 'standard' (72 frames) or 'high' (144 frames for smoother animation) */
727
+ frameQuality?: 'standard' | 'high';
643
728
  /** Polling interval in milliseconds (default: 1500) */
644
729
  pollIntervalMs?: number;
645
730
  /** Timeout in milliseconds (default: 120000 = 2 minutes) */
@@ -708,6 +793,24 @@ declare function useBouncePatternProgress(enabled?: boolean): {
708
793
  isBouncing: boolean;
709
794
  };
710
795
 
796
+ interface ContinuousSpinResult {
797
+ /** Current frame index (integer part) */
798
+ frame: number;
799
+ /** Next frame index for interpolation */
800
+ nextFrame: number;
801
+ /** Blend factor between frames (0-1), used for cross-fade interpolation */
802
+ blend: number;
803
+ }
804
+ /**
805
+ * Hook for continuous 360° spin animation with smooth interpolation support.
806
+ *
807
+ * @param enabled - Whether the animation is enabled
808
+ * @param duration - Duration in ms for one full rotation (default: 10000ms = 10 seconds)
809
+ * @param totalFrames - Total number of frames in the sprite (default: 72)
810
+ * @returns Object with current frame, next frame, and blend factor for smooth interpolation
811
+ */
812
+ declare function useContinuousSpin(enabled?: boolean, duration?: number, totalFrames?: number): ContinuousSpinResult;
813
+
711
814
  /**
712
815
  * Compares two RenderBuildRequest objects for equality by checking if the same IDs
713
816
  * are present in each category array, regardless of order.
@@ -765,6 +868,7 @@ type SpriteRenderInput = {
765
868
  showGrid?: boolean;
766
869
  cameraOffsetX?: number;
767
870
  gridSettings?: RenderGridSettings;
871
+ frameQuality?: 'standard' | 'high';
768
872
  } | {
769
873
  type: 'shareCode';
770
874
  shareCode: string;
@@ -772,6 +876,7 @@ type SpriteRenderInput = {
772
876
  showGrid?: boolean;
773
877
  cameraOffsetX?: number;
774
878
  gridSettings?: RenderGridSettings;
879
+ frameQuality?: 'standard' | 'high';
775
880
  };
776
881
  declare const useSpriteRender: (input: RenderBuildRequest | SpriteRenderInput, apiConfig: ApiConfig, onLoadStart?: () => void, options?: UseSpriteRenderOptions) => UseSpriteRenderReturn;
777
882
 
@@ -939,5 +1044,5 @@ declare const createRenderByShareCodeJob: (shareCode: string, config: ApiConfig,
939
1044
  */
940
1045
  declare const renderByShareCode: (shareCode: string, config: ApiConfig, options?: RenderByShareCodeOptions) => Promise<RenderByShareCodeResponse>;
941
1046
 
942
- export { API_BASE_URL, API_ENDPOINTS, BuildRender, BuildRenderVideo, DragIcon, InstructionTooltip, LoadingErrorOverlay, PartCategory, arePartsEqual, buildApiUrl, buildHeaders, calculateCircularFrame, calculateCircularTime, createRenderByShareCodeJob, getAvailableParts, getBuildByShareCode, getPartsByIds, renderBuildExperimental, renderByShareCode, renderSpriteExperimental, useBouncePatternProgress, useBuildRender, useSpriteRender, useSpriteScrubbing, useVideoScrubbing };
1047
+ export { API_BASE_URL, API_ENDPOINTS, BuildRender, BuildRenderVideo, DragIcon, InstructionTooltip, LoadingErrorOverlay, PartCategory, arePartsEqual, buildApiUrl, buildHeaders, calculateCircularFrame, calculateCircularTime, createRenderByShareCodeJob, getAvailableParts, getBuildByShareCode, getPartsByIds, renderBuildExperimental, renderByShareCode, renderSpriteExperimental, useBouncePatternProgress, useBuildRender, useContinuousSpin, useSpriteRender, useSpriteScrubbing, useVideoScrubbing };
943
1048
  export type { ApiConfig, AvailablePartsResponse, BuildRenderProps, BuildRenderVideoProps, BuildResponse, GetAvailablePartsOptions, GridSettings, PartDetails, PartDetailsWithCategory, PartsResponse, RenderAPIService, RenderBuildRequest, RenderBuildResponse, RenderByShareCodeJobResponse, RenderByShareCodeOptions, RenderByShareCodeResponse, RenderSpriteResponse, SpriteRenderInput, UseBuildRenderOptions, UseBuildRenderReturn, UseSpriteRenderOptions, UseSpriteRenderReturn };
package/dist/index.esm.js CHANGED
@@ -140,6 +140,57 @@ function useBouncePatternProgress(enabled = true) {
140
140
  return { value, isBouncing };
141
141
  }
142
142
 
143
+ /**
144
+ * Hook for continuous 360° spin animation with smooth interpolation support.
145
+ *
146
+ * @param enabled - Whether the animation is enabled
147
+ * @param duration - Duration in ms for one full rotation (default: 10000ms = 10 seconds)
148
+ * @param totalFrames - Total number of frames in the sprite (default: 72)
149
+ * @returns Object with current frame, next frame, and blend factor for smooth interpolation
150
+ */
151
+ function useContinuousSpin(enabled = true, duration = 10000, totalFrames = 72) {
152
+ const [result, setResult] = useState({
153
+ frame: 0,
154
+ nextFrame: 1,
155
+ blend: 0
156
+ });
157
+ const startTime = useRef(null);
158
+ // Use refs to avoid stale closures in useAnimationFrame
159
+ const totalFramesRef = useRef(totalFrames);
160
+ const durationRef = useRef(duration);
161
+ // Keep refs in sync with props
162
+ useEffect(() => {
163
+ totalFramesRef.current = totalFrames;
164
+ }, [totalFrames]);
165
+ useEffect(() => {
166
+ durationRef.current = duration;
167
+ }, [duration]);
168
+ useAnimationFrame((time) => {
169
+ if (!enabled) {
170
+ if (startTime.current !== null) {
171
+ startTime.current = null;
172
+ }
173
+ return;
174
+ }
175
+ if (startTime.current === null) {
176
+ startTime.current = time;
177
+ }
178
+ const elapsed = time - startTime.current;
179
+ const currentDuration = durationRef.current;
180
+ const currentTotalFrames = totalFramesRef.current;
181
+ // Calculate progress through the full rotation (0 to 1)
182
+ const progress = (elapsed % currentDuration) / currentDuration;
183
+ // Convert to fractional frame position
184
+ const exactFrame = progress * currentTotalFrames;
185
+ const currentFrame = Math.floor(exactFrame) % currentTotalFrames;
186
+ const nextFrame = (currentFrame + 1) % currentTotalFrames;
187
+ // Blend is the fractional part - how far between current and next frame
188
+ const blend = exactFrame - Math.floor(exactFrame);
189
+ setResult({ frame: currentFrame, nextFrame, blend });
190
+ });
191
+ return result;
192
+ }
193
+
143
194
  // API Configuration
144
195
  const API_BASE_URL = "https://www.renderapi.buildcores.com";
145
196
  // API Endpoints
@@ -215,6 +266,8 @@ const createRenderBuildJob = async (request, config) => {
215
266
  ...(request.showGrid !== undefined ? { showGrid: request.showGrid } : {}),
216
267
  ...(request.cameraOffsetX !== undefined ? { cameraOffsetX: request.cameraOffsetX } : {}),
217
268
  ...(request.gridSettings ? { gridSettings: request.gridSettings } : {}),
269
+ // Include frame quality for sprite rendering
270
+ ...(request.frameQuality ? { frameQuality: request.frameQuality } : {}),
218
271
  };
219
272
  const response = await fetch(buildApiUrl(API_ENDPOINTS.RENDER_BUILD, config), {
220
273
  method: "POST",
@@ -410,6 +463,7 @@ const createRenderByShareCodeJob = async (shareCode, config, options) => {
410
463
  ...(options?.showGrid !== undefined ? { showGrid: options.showGrid } : {}),
411
464
  ...(options?.cameraOffsetX !== undefined ? { cameraOffsetX: options.cameraOffsetX } : {}),
412
465
  ...(options?.gridSettings ? { gridSettings: options.gridSettings } : {}),
466
+ ...(options?.frameQuality ? { frameQuality: options.frameQuality } : {}),
413
467
  };
414
468
  const response = await fetch(url, {
415
469
  method: "POST",
@@ -637,28 +691,41 @@ const useSpriteRender = (input, apiConfig, onLoadStart, options) => {
637
691
  profile: currentInput.profile,
638
692
  showGrid: currentInput.showGrid,
639
693
  cameraOffsetX: currentInput.cameraOffsetX,
640
- gridSettings: currentInput.gridSettings
694
+ gridSettings: currentInput.gridSettings,
695
+ frameQuality: currentInput.frameQuality
641
696
  });
697
+ // Set metadata BEFORE sprite URL to avoid race condition
698
+ // (image load starts immediately when spriteSrc changes)
699
+ const rows = currentInput.frameQuality === 'high' ? 12 : 6;
700
+ setSpriteMetadata({ cols: 12, rows, totalFrames: 12 * rows });
642
701
  setSpriteSrc((prevSrc) => {
643
702
  if (prevSrc && prevSrc.startsWith("blob:")) {
644
703
  URL.revokeObjectURL(prevSrc);
645
704
  }
646
705
  return spriteUrl;
647
706
  });
648
- setSpriteMetadata({ cols: 12, rows: 6, totalFrames: 72 });
649
707
  return;
650
708
  }
651
709
  // Handle parts-based rendering (creates new build)
652
710
  const currentParts = currentInput.parts;
653
711
  const mode = options?.mode ?? "async";
712
+ const frameQuality = currentInput.frameQuality;
713
+ const rows = frameQuality === 'high' ? 12 : 6;
654
714
  if (mode === "experimental") {
655
715
  const response = await renderSpriteExperimental({
656
716
  ...currentParts,
657
717
  showGrid: currentInput.showGrid,
658
718
  cameraOffsetX: currentInput.cameraOffsetX,
659
719
  gridSettings: currentInput.gridSettings,
720
+ frameQuality,
660
721
  }, apiConfig);
661
722
  const objectUrl = URL.createObjectURL(response.sprite);
723
+ // Set sprite metadata BEFORE sprite URL to avoid race condition
724
+ setSpriteMetadata({
725
+ cols: response.metadata?.cols || 12,
726
+ rows: response.metadata?.rows || rows,
727
+ totalFrames: response.metadata?.totalFrames || 12 * rows,
728
+ });
662
729
  // Clean up previous sprite URL before setting new one
663
730
  setSpriteSrc((prevSrc) => {
664
731
  if (prevSrc && prevSrc.startsWith("blob:")) {
@@ -666,12 +733,6 @@ const useSpriteRender = (input, apiConfig, onLoadStart, options) => {
666
733
  }
667
734
  return objectUrl;
668
735
  });
669
- // Set sprite metadata
670
- setSpriteMetadata({
671
- cols: response.metadata?.cols || 12,
672
- rows: response.metadata?.rows || 6,
673
- totalFrames: response.metadata?.totalFrames || 72,
674
- });
675
736
  }
676
737
  else {
677
738
  // Async job-based flow: request sprite format and use returned URL
@@ -681,15 +742,16 @@ const useSpriteRender = (input, apiConfig, onLoadStart, options) => {
681
742
  showGrid: currentInput.showGrid,
682
743
  cameraOffsetX: currentInput.cameraOffsetX,
683
744
  gridSettings: currentInput.gridSettings,
745
+ frameQuality,
684
746
  }, apiConfig);
747
+ // Set metadata BEFORE sprite URL to avoid race condition
748
+ setSpriteMetadata({ cols: 12, rows, totalFrames: 12 * rows });
685
749
  setSpriteSrc((prevSrc) => {
686
750
  if (prevSrc && prevSrc.startsWith("blob:")) {
687
751
  URL.revokeObjectURL(prevSrc);
688
752
  }
689
753
  return spriteUrl;
690
754
  });
691
- // No metadata from async endpoint; keep defaults
692
- setSpriteMetadata({ cols: 12, rows: 6, totalFrames: 72 });
693
755
  }
694
756
  }
695
757
  catch (error) {
@@ -712,6 +774,7 @@ const useSpriteRender = (input, apiConfig, onLoadStart, options) => {
712
774
  a.profile === b.profile &&
713
775
  a.showGrid === b.showGrid &&
714
776
  a.cameraOffsetX === b.cameraOffsetX &&
777
+ a.frameQuality === b.frameQuality &&
715
778
  gridSettingsEqual;
716
779
  }
717
780
  if (a.type === 'parts' && b.type === 'parts') {
@@ -720,6 +783,7 @@ const useSpriteRender = (input, apiConfig, onLoadStart, options) => {
720
783
  return arePartsEqual(a.parts, b.parts) &&
721
784
  a.showGrid === b.showGrid &&
722
785
  a.cameraOffsetX === b.cameraOffsetX &&
786
+ a.frameQuality === b.frameQuality &&
723
787
  gridSettingsEqual;
724
788
  }
725
789
  return false;
@@ -895,7 +959,7 @@ const useZoomPan = ({ displayWidth, displayHeight, minScale = 1, maxScale = 4, }
895
959
  };
896
960
  };
897
961
 
898
- const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpriteRenderOptions, mouseSensitivity = 0.2, touchSensitivity = 0.2, showGrid, cameraOffsetX, gridSettings, }) => {
962
+ const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpriteRenderOptions, mouseSensitivity = 0.2, touchSensitivity = 0.2, showGrid, cameraOffsetX, gridSettings, animationMode = 'bounce', spinDuration = 10000, interactive = true, frameQuality, }) => {
899
963
  const canvasRef = useRef(null);
900
964
  const containerRef = useRef(null);
901
965
  const [img, setImg] = useState(null);
@@ -913,6 +977,7 @@ const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpri
913
977
  showGrid,
914
978
  cameraOffsetX,
915
979
  gridSettings,
980
+ frameQuality,
916
981
  };
917
982
  }
918
983
  return {
@@ -921,12 +986,15 @@ const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpri
921
986
  showGrid,
922
987
  cameraOffsetX,
923
988
  gridSettings,
989
+ frameQuality,
924
990
  };
925
- }, [shareCode, parts, showGrid, cameraOffsetX, gridSettings]);
991
+ }, [shareCode, parts, showGrid, cameraOffsetX, gridSettings, frameQuality]);
926
992
  // Use custom hook for sprite rendering
927
993
  const { spriteSrc, isRenderingSprite, renderError, spriteMetadata } = useSpriteRender(renderInput, apiConfig, undefined, useSpriteRenderOptions);
928
- const { value: progressValue, isBouncing } = useBouncePatternProgress(bouncingAllowed);
929
994
  const total = spriteMetadata ? spriteMetadata.totalFrames : 72;
995
+ // Animation hooks - only one will be active based on animationMode
996
+ const { value: progressValue, isBouncing } = useBouncePatternProgress(bouncingAllowed && animationMode === 'bounce');
997
+ const spinResult = useContinuousSpin(bouncingAllowed && animationMode === 'spin360', spinDuration, total);
930
998
  const cols = spriteMetadata ? spriteMetadata.cols : 12;
931
999
  const rows = spriteMetadata ? spriteMetadata.rows : 6;
932
1000
  const frameRef = useRef(0);
@@ -934,9 +1002,13 @@ const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpri
934
1002
  displayWidth: displayW,
935
1003
  displayHeight: displayH,
936
1004
  });
937
- // Image/frame sizes
1005
+ // Image/frame sizes - only calculate if image dimensions match expected metadata
1006
+ // This prevents using stale image with new metadata during transitions
938
1007
  const frameW = img ? img.width / cols : 0;
939
1008
  const frameH = img ? img.height / rows : 0;
1009
+ // Track expected rows to detect stale images
1010
+ const expectedRowsRef = useRef(rows);
1011
+ expectedRowsRef.current = rows;
940
1012
  // ---- Load sprite image ----
941
1013
  useEffect(() => {
942
1014
  if (!spriteSrc) {
@@ -944,12 +1016,18 @@ const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpri
944
1016
  setIsLoading(true);
945
1017
  return;
946
1018
  }
1019
+ // Clear current image immediately when source changes to prevent
1020
+ // using old image with new metadata
1021
+ setImg(null);
947
1022
  setIsLoading(true);
1023
+ setBouncingAllowed(false);
948
1024
  const i = new Image();
949
1025
  i.decoding = "async";
950
1026
  i.loading = "eager";
951
1027
  i.src = spriteSrc;
952
1028
  i.onload = () => {
1029
+ // Only set the image if rows haven't changed since we started loading
1030
+ // This prevents race conditions where metadata updates mid-load
953
1031
  setImg(i);
954
1032
  setIsLoading(false);
955
1033
  // Start bouncing animation after delay
@@ -961,9 +1039,16 @@ const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpri
961
1039
  setImg(null);
962
1040
  setIsLoading(false);
963
1041
  };
964
- }, [spriteSrc]);
965
- // ---- Drawing function ----
966
- const draw = useCallback((frameIndex) => {
1042
+ // Cleanup: if this effect re-runs (due to spriteSrc or rows change),
1043
+ // the new effect will clear the image
1044
+ return () => {
1045
+ // Abort loading if component updates before load completes
1046
+ i.onload = null;
1047
+ i.onerror = null;
1048
+ };
1049
+ }, [spriteSrc, rows]);
1050
+ // ---- Drawing function with optional cross-fade interpolation ----
1051
+ const draw = useCallback((frameIndex, nextFrameIndex, blend) => {
967
1052
  const cnv = canvasRef.current;
968
1053
  if (!cnv || !img || !frameW || !frameH)
969
1054
  return;
@@ -978,26 +1063,54 @@ const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpri
978
1063
  cnv.width = targetW;
979
1064
  cnv.height = targetH;
980
1065
  }
981
- // Snap to integer frame (never between tiles)
982
- let n = Math.round(frameIndex) % total;
983
- if (n < 0)
984
- n += total;
985
- const r = Math.floor(n / cols);
986
- const c = n % cols;
987
- // Use integer source rects to avoid sampling bleed across tiles
988
- const sx = Math.round(c * frameW);
989
- const sy = Math.round(r * frameH);
1066
+ // Calculate destination dimensions (letterboxing)
990
1067
  const sw = Math.round(frameW);
991
1068
  const sh = Math.round(frameH);
1069
+ const frameAspect = sw / sh;
1070
+ const canvasAspect = targetW / targetH;
1071
+ let drawW, drawH;
1072
+ if (frameAspect > canvasAspect) {
1073
+ drawW = targetW;
1074
+ drawH = targetW / frameAspect;
1075
+ }
1076
+ else {
1077
+ drawH = targetH;
1078
+ drawW = targetH * frameAspect;
1079
+ }
1080
+ // Apply zoom scale
1081
+ drawW *= scale;
1082
+ drawH *= scale;
1083
+ // Center in canvas
1084
+ const drawX = (targetW - drawW) / 2;
1085
+ const drawY = (targetH - drawH) / 2;
992
1086
  ctx.clearRect(0, 0, targetW, targetH);
993
1087
  ctx.imageSmoothingEnabled = true;
994
1088
  ctx.imageSmoothingQuality = "high";
995
- const scaledW = targetW * scale;
996
- const scaledH = targetH * scale;
997
- const offsetX = -((scaledW - targetW) / 2);
998
- const offsetY = -((scaledH - targetH) / 2);
999
- ctx.drawImage(img, sx, sy, sw, sh, offsetX, offsetY, scaledW, scaledH);
1000
- }, [img, frameW, frameH, displayW, displayH, cols, total, scale]);
1089
+ // Helper to draw a specific frame
1090
+ const drawFrame = (frameIdx, opacity) => {
1091
+ let n = Math.round(frameIdx) % total;
1092
+ if (n < 0)
1093
+ n += total;
1094
+ const r = Math.floor(n / cols);
1095
+ const c = n % cols;
1096
+ const sx = Math.round(c * frameW);
1097
+ const sy = Math.round(r * frameH);
1098
+ ctx.globalAlpha = opacity;
1099
+ ctx.drawImage(img, sx, sy, sw, sh, drawX, drawY, drawW, drawH);
1100
+ };
1101
+ // Cross-fade interpolation for smooth animation
1102
+ if (nextFrameIndex !== undefined && blend !== undefined && blend > 0.01) {
1103
+ // Draw current frame at full opacity first
1104
+ drawFrame(frameIndex, 1);
1105
+ // Then overlay next frame with blend opacity for smooth transition
1106
+ drawFrame(nextFrameIndex, blend);
1107
+ ctx.globalAlpha = 1; // Reset
1108
+ }
1109
+ else {
1110
+ // Single frame draw (no interpolation)
1111
+ drawFrame(frameIndex, 1);
1112
+ }
1113
+ }, [img, frameW, frameH, displayW, displayH, cols, rows, total, scale]);
1001
1114
  const { isDragging, handleMouseDown, handleTouchStart, hasDragged } = useSpriteScrubbing(canvasRef, total, {
1002
1115
  mouseSensitivity,
1003
1116
  touchSensitivity,
@@ -1010,23 +1123,30 @@ const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpri
1010
1123
  setIsLoading(true);
1011
1124
  setBouncingAllowed(false);
1012
1125
  }, []);
1013
- // Auto-rotate when bouncing is allowed and not dragged
1126
+ // Auto-rotate when animation is allowed and user hasn't manually dragged
1014
1127
  useEffect(() => {
1015
- if (hasDragged.current || !img)
1128
+ if ((interactive && hasDragged.current) || !img)
1016
1129
  return;
1017
- // Calculate frame based on progress value (similar to video time calculation)
1018
- const frame = ((progressValue / 5) * total) % total;
1019
- frameRef.current = frame;
1020
- draw(frame);
1021
- }, [progressValue, hasDragged, img, total, draw]);
1130
+ if (animationMode === 'spin360') {
1131
+ // Continuous 360 spin with cross-fade interpolation for smoothness
1132
+ frameRef.current = spinResult.frame;
1133
+ draw(spinResult.frame, spinResult.nextFrame, spinResult.blend);
1134
+ }
1135
+ else {
1136
+ // Bounce animation (no interpolation needed - it pauses between movements)
1137
+ const frame = ((progressValue / 5) * total) % total;
1138
+ frameRef.current = frame;
1139
+ draw(frame);
1140
+ }
1141
+ }, [progressValue, spinResult, hasDragged, img, total, draw, animationMode, interactive]);
1022
1142
  // Reset zoom when sprite changes or container size updates
1023
1143
  useEffect(() => {
1024
1144
  resetZoom();
1025
1145
  }, [spriteSrc, displayW, displayH, resetZoom]);
1026
- // Add native wheel event listener to prevent scrolling AND handle zoom
1146
+ // Add native wheel event listener to prevent scrolling AND handle zoom (only when interactive)
1027
1147
  useEffect(() => {
1028
1148
  const container = containerRef.current;
1029
- if (!container)
1149
+ if (!container || !interactive)
1030
1150
  return;
1031
1151
  const handleNativeWheel = (event) => {
1032
1152
  event.preventDefault();
@@ -1054,7 +1174,7 @@ const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpri
1054
1174
  return () => {
1055
1175
  container.removeEventListener('wheel', handleNativeWheel);
1056
1176
  };
1057
- }, [handleZoomWheel, scale, displayH]);
1177
+ }, [handleZoomWheel, scale, displayH, interactive]);
1058
1178
  // Initial draw once image is ready or zoom changes
1059
1179
  useEffect(() => {
1060
1180
  if (img && !isLoading) {
@@ -1078,16 +1198,19 @@ const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpri
1078
1198
  height: displayH,
1079
1199
  backgroundColor: "black",
1080
1200
  overflow: "hidden",
1081
- }, children: [img && (jsx("canvas", { ref: canvasRef, onMouseDown: handleMouseDown, onTouchStart: handleCanvasTouchStart, style: {
1201
+ }, children: [img && (jsx("canvas", { ref: canvasRef, onMouseDown: interactive ? handleMouseDown : undefined, onTouchStart: interactive ? handleCanvasTouchStart : undefined, style: {
1082
1202
  width: displayW,
1083
1203
  height: displayH,
1084
- cursor: isDragging ? "grabbing" : "grab",
1085
- touchAction: "none", // Prevents default touch behaviors like scrolling
1204
+ cursor: interactive ? (isDragging ? "grabbing" : "grab") : "pointer",
1205
+ touchAction: interactive ? "none" : "auto",
1086
1206
  display: "block",
1087
1207
  userSelect: "none",
1088
1208
  WebkitUserSelect: "none",
1089
1209
  WebkitTouchCallout: "none",
1090
- }, role: "img", "aria-label": "360\u00B0 viewer", onContextMenu: (e) => e.preventDefault() })), jsx(LoadingErrorOverlay, { isVisible: isLoading || isRenderingSprite || !!renderError, renderError: renderError || undefined, size: Math.min(displayW, displayH) }), jsx(InstructionTooltip, { isVisible: !isLoading &&
1210
+ pointerEvents: interactive ? "auto" : "none", // Allow click-through when not interactive
1211
+ }, role: "img", "aria-label": "360\u00B0 viewer", onContextMenu: interactive ? (e) => e.preventDefault() : undefined })), jsx(LoadingErrorOverlay, { isVisible: isLoading || isRenderingSprite || !!renderError, renderError: renderError || undefined, size: Math.min(displayW, displayH) }), jsx(InstructionTooltip, { isVisible: interactive &&
1212
+ animationMode === 'bounce' &&
1213
+ !isLoading &&
1091
1214
  !isRenderingSprite &&
1092
1215
  !renderError &&
1093
1216
  isBouncing &&
@@ -1247,5 +1370,5 @@ const BuildRenderVideo = ({ parts, width, height, size, apiConfig, useBuildRende
1247
1370
  !hasDragged.current, progressValue: progressValue })] }));
1248
1371
  };
1249
1372
 
1250
- export { API_BASE_URL, API_ENDPOINTS, BuildRender, BuildRenderVideo, DragIcon, InstructionTooltip, LoadingErrorOverlay, PartCategory, arePartsEqual, buildApiUrl, buildHeaders, calculateCircularFrame, calculateCircularTime, createRenderByShareCodeJob, getAvailableParts, getBuildByShareCode, getPartsByIds, renderBuildExperimental, renderByShareCode, renderSpriteExperimental, useBouncePatternProgress, useBuildRender, useSpriteRender, useSpriteScrubbing, useVideoScrubbing };
1373
+ export { API_BASE_URL, API_ENDPOINTS, BuildRender, BuildRenderVideo, DragIcon, InstructionTooltip, LoadingErrorOverlay, PartCategory, arePartsEqual, buildApiUrl, buildHeaders, calculateCircularFrame, calculateCircularTime, createRenderByShareCodeJob, getAvailableParts, getBuildByShareCode, getPartsByIds, renderBuildExperimental, renderByShareCode, renderSpriteExperimental, useBouncePatternProgress, useBuildRender, useContinuousSpin, useSpriteRender, useSpriteScrubbing, useVideoScrubbing };
1251
1374
  //# sourceMappingURL=index.esm.js.map