@buildcores/render-client 1.4.0 → 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.
package/dist/index.js CHANGED
@@ -142,6 +142,57 @@ function useBouncePatternProgress(enabled = true) {
142
142
  return { value, isBouncing };
143
143
  }
144
144
 
145
+ /**
146
+ * Hook for continuous 360° spin animation with smooth interpolation support.
147
+ *
148
+ * @param enabled - Whether the animation is enabled
149
+ * @param duration - Duration in ms for one full rotation (default: 10000ms = 10 seconds)
150
+ * @param totalFrames - Total number of frames in the sprite (default: 72)
151
+ * @returns Object with current frame, next frame, and blend factor for smooth interpolation
152
+ */
153
+ function useContinuousSpin(enabled = true, duration = 10000, totalFrames = 72) {
154
+ const [result, setResult] = react.useState({
155
+ frame: 0,
156
+ nextFrame: 1,
157
+ blend: 0
158
+ });
159
+ const startTime = react.useRef(null);
160
+ // Use refs to avoid stale closures in useAnimationFrame
161
+ const totalFramesRef = react.useRef(totalFrames);
162
+ const durationRef = react.useRef(duration);
163
+ // Keep refs in sync with props
164
+ react.useEffect(() => {
165
+ totalFramesRef.current = totalFrames;
166
+ }, [totalFrames]);
167
+ react.useEffect(() => {
168
+ durationRef.current = duration;
169
+ }, [duration]);
170
+ framerMotion.useAnimationFrame((time) => {
171
+ if (!enabled) {
172
+ if (startTime.current !== null) {
173
+ startTime.current = null;
174
+ }
175
+ return;
176
+ }
177
+ if (startTime.current === null) {
178
+ startTime.current = time;
179
+ }
180
+ const elapsed = time - startTime.current;
181
+ const currentDuration = durationRef.current;
182
+ const currentTotalFrames = totalFramesRef.current;
183
+ // Calculate progress through the full rotation (0 to 1)
184
+ const progress = (elapsed % currentDuration) / currentDuration;
185
+ // Convert to fractional frame position
186
+ const exactFrame = progress * currentTotalFrames;
187
+ const currentFrame = Math.floor(exactFrame) % currentTotalFrames;
188
+ const nextFrame = (currentFrame + 1) % currentTotalFrames;
189
+ // Blend is the fractional part - how far between current and next frame
190
+ const blend = exactFrame - Math.floor(exactFrame);
191
+ setResult({ frame: currentFrame, nextFrame, blend });
192
+ });
193
+ return result;
194
+ }
195
+
145
196
  // API Configuration
146
197
  const API_BASE_URL = "https://www.renderapi.buildcores.com";
147
198
  // API Endpoints
@@ -217,6 +268,8 @@ const createRenderBuildJob = async (request, config) => {
217
268
  ...(request.showGrid !== undefined ? { showGrid: request.showGrid } : {}),
218
269
  ...(request.cameraOffsetX !== undefined ? { cameraOffsetX: request.cameraOffsetX } : {}),
219
270
  ...(request.gridSettings ? { gridSettings: request.gridSettings } : {}),
271
+ // Include frame quality for sprite rendering
272
+ ...(request.frameQuality ? { frameQuality: request.frameQuality } : {}),
220
273
  };
221
274
  const response = await fetch(buildApiUrl(API_ENDPOINTS.RENDER_BUILD, config), {
222
275
  method: "POST",
@@ -412,6 +465,7 @@ const createRenderByShareCodeJob = async (shareCode, config, options) => {
412
465
  ...(options?.showGrid !== undefined ? { showGrid: options.showGrid } : {}),
413
466
  ...(options?.cameraOffsetX !== undefined ? { cameraOffsetX: options.cameraOffsetX } : {}),
414
467
  ...(options?.gridSettings ? { gridSettings: options.gridSettings } : {}),
468
+ ...(options?.frameQuality ? { frameQuality: options.frameQuality } : {}),
415
469
  };
416
470
  const response = await fetch(url, {
417
471
  method: "POST",
@@ -639,28 +693,41 @@ const useSpriteRender = (input, apiConfig, onLoadStart, options) => {
639
693
  profile: currentInput.profile,
640
694
  showGrid: currentInput.showGrid,
641
695
  cameraOffsetX: currentInput.cameraOffsetX,
642
- gridSettings: currentInput.gridSettings
696
+ gridSettings: currentInput.gridSettings,
697
+ frameQuality: currentInput.frameQuality
643
698
  });
699
+ // Set metadata BEFORE sprite URL to avoid race condition
700
+ // (image load starts immediately when spriteSrc changes)
701
+ const rows = currentInput.frameQuality === 'high' ? 12 : 6;
702
+ setSpriteMetadata({ cols: 12, rows, totalFrames: 12 * rows });
644
703
  setSpriteSrc((prevSrc) => {
645
704
  if (prevSrc && prevSrc.startsWith("blob:")) {
646
705
  URL.revokeObjectURL(prevSrc);
647
706
  }
648
707
  return spriteUrl;
649
708
  });
650
- setSpriteMetadata({ cols: 12, rows: 6, totalFrames: 72 });
651
709
  return;
652
710
  }
653
711
  // Handle parts-based rendering (creates new build)
654
712
  const currentParts = currentInput.parts;
655
713
  const mode = options?.mode ?? "async";
714
+ const frameQuality = currentInput.frameQuality;
715
+ const rows = frameQuality === 'high' ? 12 : 6;
656
716
  if (mode === "experimental") {
657
717
  const response = await renderSpriteExperimental({
658
718
  ...currentParts,
659
719
  showGrid: currentInput.showGrid,
660
720
  cameraOffsetX: currentInput.cameraOffsetX,
661
721
  gridSettings: currentInput.gridSettings,
722
+ frameQuality,
662
723
  }, apiConfig);
663
724
  const objectUrl = URL.createObjectURL(response.sprite);
725
+ // Set sprite metadata BEFORE sprite URL to avoid race condition
726
+ setSpriteMetadata({
727
+ cols: response.metadata?.cols || 12,
728
+ rows: response.metadata?.rows || rows,
729
+ totalFrames: response.metadata?.totalFrames || 12 * rows,
730
+ });
664
731
  // Clean up previous sprite URL before setting new one
665
732
  setSpriteSrc((prevSrc) => {
666
733
  if (prevSrc && prevSrc.startsWith("blob:")) {
@@ -668,12 +735,6 @@ const useSpriteRender = (input, apiConfig, onLoadStart, options) => {
668
735
  }
669
736
  return objectUrl;
670
737
  });
671
- // Set sprite metadata
672
- setSpriteMetadata({
673
- cols: response.metadata?.cols || 12,
674
- rows: response.metadata?.rows || 6,
675
- totalFrames: response.metadata?.totalFrames || 72,
676
- });
677
738
  }
678
739
  else {
679
740
  // Async job-based flow: request sprite format and use returned URL
@@ -683,15 +744,16 @@ const useSpriteRender = (input, apiConfig, onLoadStart, options) => {
683
744
  showGrid: currentInput.showGrid,
684
745
  cameraOffsetX: currentInput.cameraOffsetX,
685
746
  gridSettings: currentInput.gridSettings,
747
+ frameQuality,
686
748
  }, apiConfig);
749
+ // Set metadata BEFORE sprite URL to avoid race condition
750
+ setSpriteMetadata({ cols: 12, rows, totalFrames: 12 * rows });
687
751
  setSpriteSrc((prevSrc) => {
688
752
  if (prevSrc && prevSrc.startsWith("blob:")) {
689
753
  URL.revokeObjectURL(prevSrc);
690
754
  }
691
755
  return spriteUrl;
692
756
  });
693
- // No metadata from async endpoint; keep defaults
694
- setSpriteMetadata({ cols: 12, rows: 6, totalFrames: 72 });
695
757
  }
696
758
  }
697
759
  catch (error) {
@@ -714,6 +776,7 @@ const useSpriteRender = (input, apiConfig, onLoadStart, options) => {
714
776
  a.profile === b.profile &&
715
777
  a.showGrid === b.showGrid &&
716
778
  a.cameraOffsetX === b.cameraOffsetX &&
779
+ a.frameQuality === b.frameQuality &&
717
780
  gridSettingsEqual;
718
781
  }
719
782
  if (a.type === 'parts' && b.type === 'parts') {
@@ -722,6 +785,7 @@ const useSpriteRender = (input, apiConfig, onLoadStart, options) => {
722
785
  return arePartsEqual(a.parts, b.parts) &&
723
786
  a.showGrid === b.showGrid &&
724
787
  a.cameraOffsetX === b.cameraOffsetX &&
788
+ a.frameQuality === b.frameQuality &&
725
789
  gridSettingsEqual;
726
790
  }
727
791
  return false;
@@ -897,7 +961,7 @@ const useZoomPan = ({ displayWidth, displayHeight, minScale = 1, maxScale = 4, }
897
961
  };
898
962
  };
899
963
 
900
- const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpriteRenderOptions, mouseSensitivity = 0.2, touchSensitivity = 0.2, showGrid, cameraOffsetX, gridSettings, }) => {
964
+ 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, }) => {
901
965
  const canvasRef = react.useRef(null);
902
966
  const containerRef = react.useRef(null);
903
967
  const [img, setImg] = react.useState(null);
@@ -915,6 +979,7 @@ const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpri
915
979
  showGrid,
916
980
  cameraOffsetX,
917
981
  gridSettings,
982
+ frameQuality,
918
983
  };
919
984
  }
920
985
  return {
@@ -923,12 +988,15 @@ const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpri
923
988
  showGrid,
924
989
  cameraOffsetX,
925
990
  gridSettings,
991
+ frameQuality,
926
992
  };
927
- }, [shareCode, parts, showGrid, cameraOffsetX, gridSettings]);
993
+ }, [shareCode, parts, showGrid, cameraOffsetX, gridSettings, frameQuality]);
928
994
  // Use custom hook for sprite rendering
929
995
  const { spriteSrc, isRenderingSprite, renderError, spriteMetadata } = useSpriteRender(renderInput, apiConfig, undefined, useSpriteRenderOptions);
930
- const { value: progressValue, isBouncing } = useBouncePatternProgress(bouncingAllowed);
931
996
  const total = spriteMetadata ? spriteMetadata.totalFrames : 72;
997
+ // Animation hooks - only one will be active based on animationMode
998
+ const { value: progressValue, isBouncing } = useBouncePatternProgress(bouncingAllowed && animationMode === 'bounce');
999
+ const spinResult = useContinuousSpin(bouncingAllowed && animationMode === 'spin360', spinDuration, total);
932
1000
  const cols = spriteMetadata ? spriteMetadata.cols : 12;
933
1001
  const rows = spriteMetadata ? spriteMetadata.rows : 6;
934
1002
  const frameRef = react.useRef(0);
@@ -936,9 +1004,13 @@ const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpri
936
1004
  displayWidth: displayW,
937
1005
  displayHeight: displayH,
938
1006
  });
939
- // Image/frame sizes
1007
+ // Image/frame sizes - only calculate if image dimensions match expected metadata
1008
+ // This prevents using stale image with new metadata during transitions
940
1009
  const frameW = img ? img.width / cols : 0;
941
1010
  const frameH = img ? img.height / rows : 0;
1011
+ // Track expected rows to detect stale images
1012
+ const expectedRowsRef = react.useRef(rows);
1013
+ expectedRowsRef.current = rows;
942
1014
  // ---- Load sprite image ----
943
1015
  react.useEffect(() => {
944
1016
  if (!spriteSrc) {
@@ -946,12 +1018,18 @@ const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpri
946
1018
  setIsLoading(true);
947
1019
  return;
948
1020
  }
1021
+ // Clear current image immediately when source changes to prevent
1022
+ // using old image with new metadata
1023
+ setImg(null);
949
1024
  setIsLoading(true);
1025
+ setBouncingAllowed(false);
950
1026
  const i = new Image();
951
1027
  i.decoding = "async";
952
1028
  i.loading = "eager";
953
1029
  i.src = spriteSrc;
954
1030
  i.onload = () => {
1031
+ // Only set the image if rows haven't changed since we started loading
1032
+ // This prevents race conditions where metadata updates mid-load
955
1033
  setImg(i);
956
1034
  setIsLoading(false);
957
1035
  // Start bouncing animation after delay
@@ -963,9 +1041,16 @@ const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpri
963
1041
  setImg(null);
964
1042
  setIsLoading(false);
965
1043
  };
966
- }, [spriteSrc]);
967
- // ---- Drawing function ----
968
- const draw = react.useCallback((frameIndex) => {
1044
+ // Cleanup: if this effect re-runs (due to spriteSrc or rows change),
1045
+ // the new effect will clear the image
1046
+ return () => {
1047
+ // Abort loading if component updates before load completes
1048
+ i.onload = null;
1049
+ i.onerror = null;
1050
+ };
1051
+ }, [spriteSrc, rows]);
1052
+ // ---- Drawing function with optional cross-fade interpolation ----
1053
+ const draw = react.useCallback((frameIndex, nextFrameIndex, blend) => {
969
1054
  const cnv = canvasRef.current;
970
1055
  if (!cnv || !img || !frameW || !frameH)
971
1056
  return;
@@ -980,26 +1065,54 @@ const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpri
980
1065
  cnv.width = targetW;
981
1066
  cnv.height = targetH;
982
1067
  }
983
- // Snap to integer frame (never between tiles)
984
- let n = Math.round(frameIndex) % total;
985
- if (n < 0)
986
- n += total;
987
- const r = Math.floor(n / cols);
988
- const c = n % cols;
989
- // Use integer source rects to avoid sampling bleed across tiles
990
- const sx = Math.round(c * frameW);
991
- const sy = Math.round(r * frameH);
1068
+ // Calculate destination dimensions (letterboxing)
992
1069
  const sw = Math.round(frameW);
993
1070
  const sh = Math.round(frameH);
1071
+ const frameAspect = sw / sh;
1072
+ const canvasAspect = targetW / targetH;
1073
+ let drawW, drawH;
1074
+ if (frameAspect > canvasAspect) {
1075
+ drawW = targetW;
1076
+ drawH = targetW / frameAspect;
1077
+ }
1078
+ else {
1079
+ drawH = targetH;
1080
+ drawW = targetH * frameAspect;
1081
+ }
1082
+ // Apply zoom scale
1083
+ drawW *= scale;
1084
+ drawH *= scale;
1085
+ // Center in canvas
1086
+ const drawX = (targetW - drawW) / 2;
1087
+ const drawY = (targetH - drawH) / 2;
994
1088
  ctx.clearRect(0, 0, targetW, targetH);
995
1089
  ctx.imageSmoothingEnabled = true;
996
1090
  ctx.imageSmoothingQuality = "high";
997
- const scaledW = targetW * scale;
998
- const scaledH = targetH * scale;
999
- const offsetX = -((scaledW - targetW) / 2);
1000
- const offsetY = -((scaledH - targetH) / 2);
1001
- ctx.drawImage(img, sx, sy, sw, sh, offsetX, offsetY, scaledW, scaledH);
1002
- }, [img, frameW, frameH, displayW, displayH, cols, total, scale]);
1091
+ // Helper to draw a specific frame
1092
+ const drawFrame = (frameIdx, opacity) => {
1093
+ let n = Math.round(frameIdx) % total;
1094
+ if (n < 0)
1095
+ n += total;
1096
+ const r = Math.floor(n / cols);
1097
+ const c = n % cols;
1098
+ const sx = Math.round(c * frameW);
1099
+ const sy = Math.round(r * frameH);
1100
+ ctx.globalAlpha = opacity;
1101
+ ctx.drawImage(img, sx, sy, sw, sh, drawX, drawY, drawW, drawH);
1102
+ };
1103
+ // Cross-fade interpolation for smooth animation
1104
+ if (nextFrameIndex !== undefined && blend !== undefined && blend > 0.01) {
1105
+ // Draw current frame at full opacity first
1106
+ drawFrame(frameIndex, 1);
1107
+ // Then overlay next frame with blend opacity for smooth transition
1108
+ drawFrame(nextFrameIndex, blend);
1109
+ ctx.globalAlpha = 1; // Reset
1110
+ }
1111
+ else {
1112
+ // Single frame draw (no interpolation)
1113
+ drawFrame(frameIndex, 1);
1114
+ }
1115
+ }, [img, frameW, frameH, displayW, displayH, cols, rows, total, scale]);
1003
1116
  const { isDragging, handleMouseDown, handleTouchStart, hasDragged } = useSpriteScrubbing(canvasRef, total, {
1004
1117
  mouseSensitivity,
1005
1118
  touchSensitivity,
@@ -1012,23 +1125,30 @@ const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpri
1012
1125
  setIsLoading(true);
1013
1126
  setBouncingAllowed(false);
1014
1127
  }, []);
1015
- // Auto-rotate when bouncing is allowed and not dragged
1128
+ // Auto-rotate when animation is allowed and user hasn't manually dragged
1016
1129
  react.useEffect(() => {
1017
- if (hasDragged.current || !img)
1130
+ if ((interactive && hasDragged.current) || !img)
1018
1131
  return;
1019
- // Calculate frame based on progress value (similar to video time calculation)
1020
- const frame = ((progressValue / 5) * total) % total;
1021
- frameRef.current = frame;
1022
- draw(frame);
1023
- }, [progressValue, hasDragged, img, total, draw]);
1132
+ if (animationMode === 'spin360') {
1133
+ // Continuous 360 spin with cross-fade interpolation for smoothness
1134
+ frameRef.current = spinResult.frame;
1135
+ draw(spinResult.frame, spinResult.nextFrame, spinResult.blend);
1136
+ }
1137
+ else {
1138
+ // Bounce animation (no interpolation needed - it pauses between movements)
1139
+ const frame = ((progressValue / 5) * total) % total;
1140
+ frameRef.current = frame;
1141
+ draw(frame);
1142
+ }
1143
+ }, [progressValue, spinResult, hasDragged, img, total, draw, animationMode, interactive]);
1024
1144
  // Reset zoom when sprite changes or container size updates
1025
1145
  react.useEffect(() => {
1026
1146
  resetZoom();
1027
1147
  }, [spriteSrc, displayW, displayH, resetZoom]);
1028
- // Add native wheel event listener to prevent scrolling AND handle zoom
1148
+ // Add native wheel event listener to prevent scrolling AND handle zoom (only when interactive)
1029
1149
  react.useEffect(() => {
1030
1150
  const container = containerRef.current;
1031
- if (!container)
1151
+ if (!container || !interactive)
1032
1152
  return;
1033
1153
  const handleNativeWheel = (event) => {
1034
1154
  event.preventDefault();
@@ -1056,7 +1176,7 @@ const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpri
1056
1176
  return () => {
1057
1177
  container.removeEventListener('wheel', handleNativeWheel);
1058
1178
  };
1059
- }, [handleZoomWheel, scale, displayH]);
1179
+ }, [handleZoomWheel, scale, displayH, interactive]);
1060
1180
  // Initial draw once image is ready or zoom changes
1061
1181
  react.useEffect(() => {
1062
1182
  if (img && !isLoading) {
@@ -1080,16 +1200,19 @@ const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpri
1080
1200
  height: displayH,
1081
1201
  backgroundColor: "black",
1082
1202
  overflow: "hidden",
1083
- }, children: [img && (jsxRuntime.jsx("canvas", { ref: canvasRef, onMouseDown: handleMouseDown, onTouchStart: handleCanvasTouchStart, style: {
1203
+ }, children: [img && (jsxRuntime.jsx("canvas", { ref: canvasRef, onMouseDown: interactive ? handleMouseDown : undefined, onTouchStart: interactive ? handleCanvasTouchStart : undefined, style: {
1084
1204
  width: displayW,
1085
1205
  height: displayH,
1086
- cursor: isDragging ? "grabbing" : "grab",
1087
- touchAction: "none", // Prevents default touch behaviors like scrolling
1206
+ cursor: interactive ? (isDragging ? "grabbing" : "grab") : "pointer",
1207
+ touchAction: interactive ? "none" : "auto",
1088
1208
  display: "block",
1089
1209
  userSelect: "none",
1090
1210
  WebkitUserSelect: "none",
1091
1211
  WebkitTouchCallout: "none",
1092
- }, role: "img", "aria-label": "360\u00B0 viewer", onContextMenu: (e) => e.preventDefault() })), jsxRuntime.jsx(LoadingErrorOverlay, { isVisible: isLoading || isRenderingSprite || !!renderError, renderError: renderError || undefined, size: Math.min(displayW, displayH) }), jsxRuntime.jsx(InstructionTooltip, { isVisible: !isLoading &&
1212
+ pointerEvents: interactive ? "auto" : "none", // Allow click-through when not interactive
1213
+ }, role: "img", "aria-label": "360\u00B0 viewer", onContextMenu: interactive ? (e) => e.preventDefault() : undefined })), jsxRuntime.jsx(LoadingErrorOverlay, { isVisible: isLoading || isRenderingSprite || !!renderError, renderError: renderError || undefined, size: Math.min(displayW, displayH) }), jsxRuntime.jsx(InstructionTooltip, { isVisible: interactive &&
1214
+ animationMode === 'bounce' &&
1215
+ !isLoading &&
1093
1216
  !isRenderingSprite &&
1094
1217
  !renderError &&
1095
1218
  isBouncing &&
@@ -1270,6 +1393,7 @@ exports.renderByShareCode = renderByShareCode;
1270
1393
  exports.renderSpriteExperimental = renderSpriteExperimental;
1271
1394
  exports.useBouncePatternProgress = useBouncePatternProgress;
1272
1395
  exports.useBuildRender = useBuildRender;
1396
+ exports.useContinuousSpin = useContinuousSpin;
1273
1397
  exports.useSpriteRender = useSpriteRender;
1274
1398
  exports.useSpriteScrubbing = useSpriteScrubbing;
1275
1399
  exports.useVideoScrubbing = useVideoScrubbing;