@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/README.md CHANGED
@@ -69,6 +69,9 @@ interface RenderBuildRequest {
69
69
  parts: {
70
70
  [K in PartCategory]?: string[];
71
71
  };
72
+ format?: "video" | "sprite";
73
+ width?: number; // Optional: Canvas pixel width (256-2000)
74
+ height?: number; // Optional: Canvas pixel height (256-2000)
72
75
  }
73
76
 
74
77
  // Available part categories
@@ -86,6 +89,8 @@ enum PartCategory {
86
89
 
87
90
  **Current Limitation**: Each category array must contain exactly one part ID. Multiple parts per category will be supported in future versions.
88
91
 
92
+ **Resolution Control**: You can specify custom `width` and `height` (both must be provided together, 256-2000 pixels) for higher or lower quality renders. If not specified, the default resolution is used.
93
+
89
94
  #### Examples
90
95
 
91
96
  **Complete Build (All Components)**
@@ -119,6 +124,27 @@ const caseOnly = {
119
124
  <BuildRender parts={caseOnly} size={500} />;
120
125
  ```
121
126
 
127
+ **Custom Resolution (High Quality)**
128
+
129
+ ```tsx
130
+ const highResBuild = {
131
+ parts: {
132
+ CPU: ["7xjqsomhr"],
133
+ GPU: ["z7pyphm9k"],
134
+ RAM: ["dpl1iyvb5"],
135
+ Motherboard: ["iwin2u9vx"],
136
+ PSU: ["m4kilv190"],
137
+ Storage: ["0bkvs17po"],
138
+ PCCase: ["qq9jamk7c"],
139
+ CPUCooler: ["62d8zelr5"],
140
+ },
141
+ width: 1920, // Custom resolution width
142
+ height: 1080, // Custom resolution height
143
+ };
144
+
145
+ <BuildRender parts={highResBuild} size={500} />;
146
+ ```
147
+
122
148
  ### `getAvailableParts()` Function
123
149
 
124
150
  Fetches all available PC parts from the BuildCores API.
@@ -0,0 +1,16 @@
1
+ import { type TouchEvent as ReactTouchEvent, type WheelEvent as ReactWheelEvent } from "react";
2
+ interface UseZoomOptions {
3
+ displayWidth?: number;
4
+ displayHeight?: number;
5
+ minScale?: number;
6
+ maxScale?: number;
7
+ }
8
+ interface UseZoomReturn {
9
+ scale: number;
10
+ isPinching: boolean;
11
+ handleWheel: (event: ReactWheelEvent<Element>) => void;
12
+ handleTouchStart: (event: ReactTouchEvent<HTMLCanvasElement>) => boolean;
13
+ reset: () => void;
14
+ }
15
+ export declare const useZoomPan: ({ displayWidth, displayHeight, minScale, maxScale, }?: UseZoomOptions) => UseZoomReturn;
16
+ export {};
package/dist/index.d.ts CHANGED
@@ -340,6 +340,58 @@ interface RenderBuildRequest {
340
340
  * @default "video"
341
341
  */
342
342
  format?: "video" | "sprite";
343
+ /**
344
+ * Desired canvas pixel width (256-2000).
345
+ * Must be provided together with height.
346
+ *
347
+ * @example
348
+ * ```tsx
349
+ * const request: RenderBuildRequest = {
350
+ * parts: { CPU: ["7xjqsomhr"] },
351
+ * width: 1920,
352
+ * height: 1080
353
+ * };
354
+ * ```
355
+ */
356
+ width?: number;
357
+ /**
358
+ * Desired canvas pixel height (256-2000).
359
+ * Must be provided together with width.
360
+ *
361
+ * @example
362
+ * ```tsx
363
+ * const request: RenderBuildRequest = {
364
+ * parts: { CPU: ["7xjqsomhr"] },
365
+ * width: 1920,
366
+ * height: 1080
367
+ * };
368
+ * ```
369
+ */
370
+ height?: number;
371
+ /**
372
+ * Render quality profile that controls visual effects and rendering speed.
373
+ *
374
+ * - **cinematic**: All effects enabled (shadows, ambient occlusion, bloom) for highest quality
375
+ * - **flat**: No effects for clean, simple product shots
376
+ * - **fast**: Minimal rendering for fastest processing speed
377
+ *
378
+ * @example
379
+ * ```tsx
380
+ * const request: RenderBuildRequest = {
381
+ * parts: { CPU: ["7xjqsomhr"] },
382
+ * profile: 'cinematic' // High quality with all effects
383
+ * };
384
+ * ```
385
+ *
386
+ * @example Fast rendering
387
+ * ```tsx
388
+ * const request: RenderBuildRequest = {
389
+ * parts: { CPU: ["7xjqsomhr"] },
390
+ * profile: 'fast' // Quick render, minimal effects
391
+ * };
392
+ * ```
393
+ */
394
+ profile?: 'cinematic' | 'flat' | 'fast';
343
395
  }
344
396
  /**
345
397
  * Response structure containing all available parts for each category.
package/dist/index.esm.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
2
- import { useState, useRef, useCallback, useEffect, createContext, useContext } from 'react';
2
+ import { useState, useRef, useCallback, useEffect } from 'react';
3
+ import { useAnimationFrame } from 'framer-motion';
3
4
 
4
5
  // Helper to extract clientX from mouse or touch events
5
6
  const getClientX$1 = (e) => {
@@ -102,186 +103,6 @@ const useSpriteScrubbing = (canvasRef, totalFrames, options = {}) => {
102
103
  };
103
104
  };
104
105
 
105
- /**
106
- * @public
107
- */
108
- const MotionConfigContext = createContext({
109
- transformPagePoint: (p) => p,
110
- isStatic: false,
111
- reducedMotion: "never",
112
- });
113
-
114
- /*#__NO_SIDE_EFFECTS__*/
115
- const noop = (any) => any;
116
-
117
- if (process.env.NODE_ENV !== "production") ;
118
-
119
- function createRenderStep(runNextFrame) {
120
- /**
121
- * We create and reuse two queues, one to queue jobs for the current frame
122
- * and one for the next. We reuse to avoid triggering GC after x frames.
123
- */
124
- let thisFrame = new Set();
125
- let nextFrame = new Set();
126
- /**
127
- * Track whether we're currently processing jobs in this step. This way
128
- * we can decide whether to schedule new jobs for this frame or next.
129
- */
130
- let isProcessing = false;
131
- let flushNextFrame = false;
132
- /**
133
- * A set of processes which were marked keepAlive when scheduled.
134
- */
135
- const toKeepAlive = new WeakSet();
136
- let latestFrameData = {
137
- delta: 0.0,
138
- timestamp: 0.0,
139
- isProcessing: false,
140
- };
141
- function triggerCallback(callback) {
142
- if (toKeepAlive.has(callback)) {
143
- step.schedule(callback);
144
- runNextFrame();
145
- }
146
- callback(latestFrameData);
147
- }
148
- const step = {
149
- /**
150
- * Schedule a process to run on the next frame.
151
- */
152
- schedule: (callback, keepAlive = false, immediate = false) => {
153
- const addToCurrentFrame = immediate && isProcessing;
154
- const queue = addToCurrentFrame ? thisFrame : nextFrame;
155
- if (keepAlive)
156
- toKeepAlive.add(callback);
157
- if (!queue.has(callback))
158
- queue.add(callback);
159
- return callback;
160
- },
161
- /**
162
- * Cancel the provided callback from running on the next frame.
163
- */
164
- cancel: (callback) => {
165
- nextFrame.delete(callback);
166
- toKeepAlive.delete(callback);
167
- },
168
- /**
169
- * Execute all schedule callbacks.
170
- */
171
- process: (frameData) => {
172
- latestFrameData = frameData;
173
- /**
174
- * If we're already processing we've probably been triggered by a flushSync
175
- * inside an existing process. Instead of executing, mark flushNextFrame
176
- * as true and ensure we flush the following frame at the end of this one.
177
- */
178
- if (isProcessing) {
179
- flushNextFrame = true;
180
- return;
181
- }
182
- isProcessing = true;
183
- [thisFrame, nextFrame] = [nextFrame, thisFrame];
184
- // Execute this frame
185
- thisFrame.forEach(triggerCallback);
186
- // Clear the frame so no callbacks remain. This is to avoid
187
- // memory leaks should this render step not run for a while.
188
- thisFrame.clear();
189
- isProcessing = false;
190
- if (flushNextFrame) {
191
- flushNextFrame = false;
192
- step.process(frameData);
193
- }
194
- },
195
- };
196
- return step;
197
- }
198
-
199
- const stepsOrder = [
200
- "read", // Read
201
- "resolveKeyframes", // Write/Read/Write/Read
202
- "update", // Compute
203
- "preRender", // Compute
204
- "render", // Write
205
- "postRender", // Compute
206
- ];
207
- const maxElapsed = 40;
208
- function createRenderBatcher(scheduleNextBatch, allowKeepAlive) {
209
- let runNextFrame = false;
210
- let useDefaultElapsed = true;
211
- const state = {
212
- delta: 0.0,
213
- timestamp: 0.0,
214
- isProcessing: false,
215
- };
216
- const flagRunNextFrame = () => (runNextFrame = true);
217
- const steps = stepsOrder.reduce((acc, key) => {
218
- acc[key] = createRenderStep(flagRunNextFrame);
219
- return acc;
220
- }, {});
221
- const { read, resolveKeyframes, update, preRender, render, postRender } = steps;
222
- const processBatch = () => {
223
- const timestamp = performance.now();
224
- runNextFrame = false;
225
- state.delta = useDefaultElapsed
226
- ? 1000 / 60
227
- : Math.max(Math.min(timestamp - state.timestamp, maxElapsed), 1);
228
- state.timestamp = timestamp;
229
- state.isProcessing = true;
230
- // Unrolled render loop for better per-frame performance
231
- read.process(state);
232
- resolveKeyframes.process(state);
233
- update.process(state);
234
- preRender.process(state);
235
- render.process(state);
236
- postRender.process(state);
237
- state.isProcessing = false;
238
- if (runNextFrame && allowKeepAlive) {
239
- useDefaultElapsed = false;
240
- scheduleNextBatch(processBatch);
241
- }
242
- };
243
- const wake = () => {
244
- runNextFrame = true;
245
- useDefaultElapsed = true;
246
- if (!state.isProcessing) {
247
- scheduleNextBatch(processBatch);
248
- }
249
- };
250
- const schedule = stepsOrder.reduce((acc, key) => {
251
- const step = steps[key];
252
- acc[key] = (process, keepAlive = false, immediate = false) => {
253
- if (!runNextFrame)
254
- wake();
255
- return step.schedule(process, keepAlive, immediate);
256
- };
257
- return acc;
258
- }, {});
259
- const cancel = (process) => {
260
- for (let i = 0; i < stepsOrder.length; i++) {
261
- steps[stepsOrder[i]].cancel(process);
262
- }
263
- };
264
- return { schedule, cancel, state, steps };
265
- }
266
-
267
- const { schedule: frame, cancel: cancelFrame} = createRenderBatcher(typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame : noop, true);
268
-
269
- function useAnimationFrame(callback) {
270
- const initialTimestamp = useRef(0);
271
- const { isStatic } = useContext(MotionConfigContext);
272
- useEffect(() => {
273
- if (isStatic)
274
- return;
275
- const provideTimeSinceStart = ({ timestamp, delta }) => {
276
- if (!initialTimestamp.current)
277
- initialTimestamp.current = timestamp;
278
- callback(timestamp - initialTimestamp.current, delta);
279
- };
280
- frame.update(provideTimeSinceStart, true);
281
- return () => cancelFrame(provideTimeSinceStart);
282
- }, [callback]);
283
- }
284
-
285
106
  function useBouncePatternProgress(enabled = true) {
286
107
  const [value, setValue] = useState(0);
287
108
  const [isBouncing, setIsBouncing] = useState(false);
@@ -353,6 +174,11 @@ const renderBuildExperimental = async (request, config) => {
353
174
  const requestWithFormat = {
354
175
  ...request,
355
176
  format: request.format || "video", // Default to video format
177
+ // Include width and height if provided
178
+ ...(request.width !== undefined ? { width: request.width } : {}),
179
+ ...(request.height !== undefined ? { height: request.height } : {}),
180
+ // Include profile if provided
181
+ ...(request.profile ? { profile: request.profile } : {}),
356
182
  };
357
183
  const response = await fetch(buildApiUrl(API_ENDPOINTS.RENDER_BUILD_EXPERIMENTAL, config), {
358
184
  method: "POST",
@@ -377,6 +203,11 @@ const createRenderBuildJob = async (request, config) => {
377
203
  parts: request.parts,
378
204
  // If provided, forward format; default handled server-side but we keep explicit default
379
205
  ...(request.format ? { format: request.format } : {}),
206
+ // Include width and height if provided
207
+ ...(request.width !== undefined ? { width: request.width } : {}),
208
+ ...(request.height !== undefined ? { height: request.height } : {}),
209
+ // Include profile if provided
210
+ ...(request.profile ? { profile: request.profile } : {}),
380
211
  };
381
212
  const response = await fetch(buildApiUrl(API_ENDPOINTS.RENDER_BUILD, config), {
382
213
  method: "POST",
@@ -437,6 +268,11 @@ const renderSpriteExperimental = async (request, config) => {
437
268
  const requestWithFormat = {
438
269
  ...request,
439
270
  format: "sprite",
271
+ // Include width and height if provided
272
+ ...(request.width !== undefined ? { width: request.width } : {}),
273
+ ...(request.height !== undefined ? { height: request.height } : {}),
274
+ // Include profile if provided
275
+ ...(request.profile ? { profile: request.profile } : {}),
440
276
  };
441
277
  const response = await fetch(buildApiUrl(API_ENDPOINTS.RENDER_BUILD_EXPERIMENTAL, config), {
442
278
  method: "POST",
@@ -738,8 +574,101 @@ const InstructionTooltip = ({ isVisible, progressValue, instructionIcon, }) => {
738
574
  } })) }));
739
575
  };
740
576
 
577
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
578
+ const getTouchDistance = (touches) => {
579
+ const first = touches[0];
580
+ const second = touches[1];
581
+ if (!first || !second)
582
+ return 0;
583
+ return Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY);
584
+ };
585
+ const useZoomPan = ({ displayWidth, displayHeight, minScale = 1, maxScale = 4, } = {}) => {
586
+ const [scale, setScale] = useState(1);
587
+ const [isPinching, setIsPinching] = useState(false);
588
+ const scaleRef = useRef(1);
589
+ const pinchDataRef = useRef({
590
+ initialDistance: 0,
591
+ initialScale: 1,
592
+ });
593
+ const setScaleSafe = useCallback((next) => {
594
+ const clamped = clamp(next, minScale, maxScale);
595
+ if (clamped === scaleRef.current)
596
+ return;
597
+ scaleRef.current = clamped;
598
+ setScale(clamped);
599
+ }, [minScale, maxScale]);
600
+ const handleWheel = useCallback((event) => {
601
+ event.preventDefault();
602
+ event.stopPropagation();
603
+ const deltaY = event.deltaMode === 1
604
+ ? event.deltaY * 16
605
+ : event.deltaMode === 2
606
+ ? event.deltaY * (displayHeight ?? 300)
607
+ : event.deltaY;
608
+ const zoomFactor = Math.exp(-deltaY * 0.0015);
609
+ const nextScale = scaleRef.current * zoomFactor;
610
+ setScaleSafe(nextScale);
611
+ }, [setScaleSafe, displayHeight]);
612
+ const handleTouchStart = useCallback((event) => {
613
+ if (event.touches.length < 2) {
614
+ return false;
615
+ }
616
+ const distance = getTouchDistance(event.touches);
617
+ if (!distance) {
618
+ return false;
619
+ }
620
+ pinchDataRef.current = {
621
+ initialDistance: distance,
622
+ initialScale: scaleRef.current,
623
+ };
624
+ setIsPinching(true);
625
+ event.preventDefault();
626
+ return true;
627
+ }, []);
628
+ useEffect(() => {
629
+ if (!isPinching)
630
+ return;
631
+ const handleMove = (event) => {
632
+ if (event.touches.length < 2)
633
+ return;
634
+ const distance = getTouchDistance(event.touches);
635
+ if (!distance || pinchDataRef.current.initialDistance === 0)
636
+ return;
637
+ const scaleFactor = distance / pinchDataRef.current.initialDistance;
638
+ const nextScale = pinchDataRef.current.initialScale * scaleFactor;
639
+ setScaleSafe(nextScale);
640
+ event.preventDefault();
641
+ };
642
+ const handleEnd = (event) => {
643
+ if (event.touches.length < 2) {
644
+ setIsPinching(false);
645
+ }
646
+ };
647
+ window.addEventListener("touchmove", handleMove, { passive: false });
648
+ window.addEventListener("touchend", handleEnd);
649
+ window.addEventListener("touchcancel", handleEnd);
650
+ return () => {
651
+ window.removeEventListener("touchmove", handleMove);
652
+ window.removeEventListener("touchend", handleEnd);
653
+ window.removeEventListener("touchcancel", handleEnd);
654
+ };
655
+ }, [isPinching, setScaleSafe]);
656
+ const reset = useCallback(() => {
657
+ scaleRef.current = 1;
658
+ setScale(1);
659
+ }, []);
660
+ return {
661
+ scale,
662
+ isPinching,
663
+ handleWheel,
664
+ handleTouchStart,
665
+ reset,
666
+ };
667
+ };
668
+
741
669
  const BuildRender = ({ parts, width, height, size, apiConfig, useSpriteRenderOptions, mouseSensitivity = 0.2, touchSensitivity = 0.2, }) => {
742
670
  const canvasRef = useRef(null);
671
+ const containerRef = useRef(null);
743
672
  const [img, setImg] = useState(null);
744
673
  const [isLoading, setIsLoading] = useState(true);
745
674
  const [bouncingAllowed, setBouncingAllowed] = useState(false);
@@ -752,6 +681,10 @@ const BuildRender = ({ parts, width, height, size, apiConfig, useSpriteRenderOpt
752
681
  const cols = spriteMetadata ? spriteMetadata.cols : 12;
753
682
  const rows = spriteMetadata ? spriteMetadata.rows : 6;
754
683
  const frameRef = useRef(0);
684
+ const { scale, handleWheel: handleZoomWheel, handleTouchStart: handleZoomTouchStart, reset: resetZoom, } = useZoomPan({
685
+ displayWidth: displayW,
686
+ displayHeight: displayH,
687
+ });
755
688
  // Image/frame sizes
756
689
  const frameW = img ? img.width / cols : 0;
757
690
  const frameH = img ? img.height / rows : 0;
@@ -810,8 +743,12 @@ const BuildRender = ({ parts, width, height, size, apiConfig, useSpriteRenderOpt
810
743
  ctx.clearRect(0, 0, targetW, targetH);
811
744
  ctx.imageSmoothingEnabled = true;
812
745
  ctx.imageSmoothingQuality = "high";
813
- ctx.drawImage(img, sx, sy, sw, sh, 0, 0, targetW, targetH);
814
- }, [img, frameW, frameH, displayW, displayH, cols, total]);
746
+ const scaledW = targetW * scale;
747
+ const scaledH = targetH * scale;
748
+ const offsetX = -((scaledW - targetW) / 2);
749
+ const offsetY = -((scaledH - targetH) / 2);
750
+ ctx.drawImage(img, sx, sy, sw, sh, offsetX, offsetY, scaledW, scaledH);
751
+ }, [img, frameW, frameH, displayW, displayH, cols, total, scale]);
815
752
  const { isDragging, handleMouseDown, handleTouchStart, hasDragged } = useSpriteScrubbing(canvasRef, total, {
816
753
  mouseSensitivity,
817
754
  touchSensitivity,
@@ -833,18 +770,66 @@ const BuildRender = ({ parts, width, height, size, apiConfig, useSpriteRenderOpt
833
770
  frameRef.current = frame;
834
771
  draw(frame);
835
772
  }, [progressValue, hasDragged, img, total, draw]);
836
- // Initial draw once image is ready
773
+ // Reset zoom when sprite changes or container size updates
774
+ useEffect(() => {
775
+ resetZoom();
776
+ }, [spriteSrc, displayW, displayH, resetZoom]);
777
+ // Add native wheel event listener to prevent scrolling AND handle zoom
778
+ useEffect(() => {
779
+ const container = containerRef.current;
780
+ if (!container)
781
+ return;
782
+ const handleNativeWheel = (event) => {
783
+ event.preventDefault();
784
+ event.stopPropagation();
785
+ // Manually trigger zoom since we're preventing the React event
786
+ event.deltaMode === 1
787
+ ? event.deltaY * 16
788
+ : event.deltaMode === 2
789
+ ? event.deltaY * (displayH ?? 300)
790
+ : event.deltaY;
791
+ // We need to call the zoom handler directly
792
+ // Create a synthetic React event-like object
793
+ const syntheticEvent = {
794
+ preventDefault: () => { },
795
+ stopPropagation: () => { },
796
+ deltaY: event.deltaY,
797
+ deltaMode: event.deltaMode,
798
+ currentTarget: container,
799
+ };
800
+ handleZoomWheel(syntheticEvent);
801
+ hasDragged.current = true;
802
+ };
803
+ // Add listener to container to catch all wheel events
804
+ container.addEventListener('wheel', handleNativeWheel, { passive: false });
805
+ return () => {
806
+ container.removeEventListener('wheel', handleNativeWheel);
807
+ };
808
+ }, [handleZoomWheel, scale, displayH]);
809
+ // Initial draw once image is ready or zoom changes
837
810
  useEffect(() => {
838
811
  if (img && !isLoading) {
839
812
  draw(frameRef.current);
840
813
  }
841
814
  }, [img, isLoading, draw]);
842
- return (jsxs("div", { style: {
815
+ const handleCanvasTouchStart = useCallback((event) => {
816
+ if (handleZoomTouchStart(event)) {
817
+ hasDragged.current = true;
818
+ return;
819
+ }
820
+ handleTouchStart(event);
821
+ }, [handleZoomTouchStart, handleTouchStart, hasDragged]);
822
+ useCallback((event) => {
823
+ hasDragged.current = true;
824
+ handleZoomWheel(event);
825
+ }, [handleZoomWheel, hasDragged]);
826
+ return (jsxs("div", { ref: containerRef, style: {
843
827
  position: "relative",
844
828
  width: displayW,
845
829
  height: displayH,
846
830
  backgroundColor: "black",
847
- }, children: [img && (jsx("canvas", { ref: canvasRef, onMouseDown: handleMouseDown, onTouchStart: handleTouchStart, style: {
831
+ overflow: "hidden",
832
+ }, children: [img && (jsx("canvas", { ref: canvasRef, onMouseDown: handleMouseDown, onTouchStart: handleCanvasTouchStart, style: {
848
833
  width: displayW,
849
834
  height: displayH,
850
835
  cursor: isDragging ? "grabbing" : "grab",