@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/hooks/useContinuousSpin.d.ts +17 -0
- package/dist/hooks/useSpriteRender.d.ts +2 -0
- package/dist/index.d.ts +106 -1
- package/dist/index.esm.js +170 -47
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +170 -46
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +85 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
996
|
-
const
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
|
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
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
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"
|
|
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
|
-
|
|
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
|