@buildcores/render-client 1.3.0 → 1.4.1

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.esm.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
2
- import { useState, useRef, useCallback, useEffect } from 'react';
2
+ import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
3
3
  import { useAnimationFrame } from 'framer-motion';
4
4
 
5
5
  // Helper to extract clientX from mouse or touch events
@@ -147,6 +147,9 @@ const API_ENDPOINTS = {
147
147
  RENDER_BUILD_EXPERIMENTAL: "/render-build-experimental",
148
148
  RENDER_BUILD: "/render-build",
149
149
  AVAILABLE_PARTS: "/available-parts",
150
+ BUILD: "/build",
151
+ PARTS: "/parts",
152
+ RENDER_BY_SHARE_CODE: "/render-by-share-code",
150
153
  };
151
154
  // API URL helpers
152
155
  const buildApiUrl = (endpoint, config) => {
@@ -208,6 +211,10 @@ const createRenderBuildJob = async (request, config) => {
208
211
  ...(request.height !== undefined ? { height: request.height } : {}),
209
212
  // Include profile if provided
210
213
  ...(request.profile ? { profile: request.profile } : {}),
214
+ // Include composition settings
215
+ ...(request.showGrid !== undefined ? { showGrid: request.showGrid } : {}),
216
+ ...(request.cameraOffsetX !== undefined ? { cameraOffsetX: request.cameraOffsetX } : {}),
217
+ ...(request.gridSettings ? { gridSettings: request.gridSettings } : {}),
211
218
  };
212
219
  const response = await fetch(buildApiUrl(API_ENDPOINTS.RENDER_BUILD, config), {
213
220
  method: "POST",
@@ -313,6 +320,169 @@ const getAvailableParts = async (category, config, options) => {
313
320
  }
314
321
  return (await response.json());
315
322
  };
323
+ // ============================================
324
+ // Build and Parts API Functions
325
+ // ============================================
326
+ /**
327
+ * Fetch a build by its share code.
328
+ * Returns build metadata and parts organized by category.
329
+ *
330
+ * @param shareCode - The share code of the build to fetch
331
+ * @param config - API configuration (environment, auth token)
332
+ * @returns Promise with build details including parts
333
+ *
334
+ * @example
335
+ * ```tsx
336
+ * const build = await getBuildByShareCode('abc123xyz', {
337
+ * environment: 'prod',
338
+ * authToken: 'your-api-key'
339
+ * });
340
+ *
341
+ * console.log(build.name); // "My Gaming PC"
342
+ * console.log(build.parts.CPU); // ["7xjqsomhr"]
343
+ *
344
+ * // Use the parts directly with BuildRender
345
+ * <BuildRender parts={{ parts: build.parts }} size={500} apiConfig={config} />
346
+ * ```
347
+ */
348
+ const getBuildByShareCode = async (shareCode, config) => {
349
+ const url = buildApiUrl(`${API_ENDPOINTS.BUILD}/${encodeURIComponent(shareCode)}`, config);
350
+ const response = await fetch(url, {
351
+ method: "GET",
352
+ headers: buildHeaders(config),
353
+ });
354
+ if (response.status === 404) {
355
+ throw new Error("Build not found");
356
+ }
357
+ if (!response.ok) {
358
+ throw new Error(`Get build by share code failed: ${response.status} ${response.statusText}`);
359
+ }
360
+ return (await response.json());
361
+ };
362
+ /**
363
+ * Fetch part details by their BuildCores IDs.
364
+ *
365
+ * @param partIds - Array of BuildCores part IDs to fetch
366
+ * @param config - API configuration (environment, auth token)
367
+ * @returns Promise with part details
368
+ *
369
+ * @example
370
+ * ```tsx
371
+ * const response = await getPartsByIds(['7xjqsomhr', 'z7pyphm9k'], {
372
+ * environment: 'prod',
373
+ * authToken: 'your-api-key'
374
+ * });
375
+ *
376
+ * response.parts.forEach(part => {
377
+ * console.log(`${part.name} (${part.category})`);
378
+ * });
379
+ * ```
380
+ */
381
+ const getPartsByIds = async (partIds, config) => {
382
+ const url = buildApiUrl(API_ENDPOINTS.PARTS, config);
383
+ const response = await fetch(url, {
384
+ method: "POST",
385
+ headers: buildHeaders(config),
386
+ body: JSON.stringify({ ids: partIds }),
387
+ });
388
+ if (!response.ok) {
389
+ throw new Error(`Get parts by IDs failed: ${response.status} ${response.statusText}`);
390
+ }
391
+ return (await response.json());
392
+ };
393
+ /**
394
+ * Create a render job for a build by its share code.
395
+ * Returns the job ID for polling status.
396
+ *
397
+ * @param shareCode - The share code of the build to render
398
+ * @param config - API configuration (environment, auth token)
399
+ * @param options - Render options (format, dimensions, profile)
400
+ * @returns Promise with job creation response
401
+ */
402
+ const createRenderByShareCodeJob = async (shareCode, config, options) => {
403
+ const url = buildApiUrl(API_ENDPOINTS.RENDER_BY_SHARE_CODE, config);
404
+ const body = {
405
+ shareCode,
406
+ ...(options?.format ? { format: options.format } : {}),
407
+ ...(options?.width !== undefined ? { width: options.width } : {}),
408
+ ...(options?.height !== undefined ? { height: options.height } : {}),
409
+ ...(options?.profile ? { profile: options.profile } : {}),
410
+ ...(options?.showGrid !== undefined ? { showGrid: options.showGrid } : {}),
411
+ ...(options?.cameraOffsetX !== undefined ? { cameraOffsetX: options.cameraOffsetX } : {}),
412
+ ...(options?.gridSettings ? { gridSettings: options.gridSettings } : {}),
413
+ };
414
+ const response = await fetch(url, {
415
+ method: "POST",
416
+ headers: buildHeaders(config),
417
+ body: JSON.stringify(body),
418
+ });
419
+ if (response.status === 404) {
420
+ throw new Error("Build not found");
421
+ }
422
+ if (!response.ok) {
423
+ throw new Error(`Create render job failed: ${response.status} ${response.statusText}`);
424
+ }
425
+ const data = (await response.json());
426
+ if (!data?.job_id) {
427
+ throw new Error("Create render job failed: missing job_id in response");
428
+ }
429
+ return data;
430
+ };
431
+ /**
432
+ * Render a build by its share code.
433
+ * This is a convenience function that creates a render job and polls until completion.
434
+ *
435
+ * @param shareCode - The share code of the build to render
436
+ * @param config - API configuration (environment, auth token)
437
+ * @param options - Render options including polling configuration
438
+ * @returns Promise with the final render URL
439
+ *
440
+ * @example
441
+ * ```tsx
442
+ * // Simple usage - just pass share code
443
+ * const result = await renderByShareCode('abc123xyz', {
444
+ * environment: 'prod',
445
+ * authToken: 'your-api-key'
446
+ * });
447
+ * console.log(result.videoUrl); // URL to rendered video
448
+ *
449
+ * // With custom options
450
+ * const result = await renderByShareCode('abc123xyz', config, {
451
+ * format: 'sprite',
452
+ * width: 1920,
453
+ * height: 1080,
454
+ * profile: 'cinematic',
455
+ * timeoutMs: 180000 // 3 minute timeout
456
+ * });
457
+ * ```
458
+ */
459
+ const renderByShareCode = async (shareCode, config, options) => {
460
+ const pollIntervalMs = options?.pollIntervalMs ?? 1500;
461
+ const timeoutMs = options?.timeoutMs ?? 120000; // 2 minutes default
462
+ const { job_id } = await createRenderByShareCodeJob(shareCode, config, options);
463
+ const start = Date.now();
464
+ // Poll until completed or error or timeout
465
+ for (;;) {
466
+ const status = await getRenderBuildStatus(job_id, config);
467
+ if (status.status === "completed") {
468
+ const requestedFormat = options?.format ?? "video";
469
+ const finalUrl = requestedFormat === "sprite"
470
+ ? status.sprite_url || status.url || undefined
471
+ : status.video_url || status.url || undefined;
472
+ if (!finalUrl) {
473
+ throw new Error("Render job completed but no URL returned");
474
+ }
475
+ return { videoUrl: finalUrl };
476
+ }
477
+ if (status.status === "error") {
478
+ throw new Error(status.error || "Render job failed");
479
+ }
480
+ if (Date.now() - start > timeoutMs) {
481
+ throw new Error("Timed out waiting for render job to complete");
482
+ }
483
+ await sleep(pollIntervalMs);
484
+ }
485
+ };
316
486
 
317
487
  /**
318
488
  * Enum defining all available PC part categories that can be rendered.
@@ -353,6 +523,8 @@ var PartCategory;
353
523
  PartCategory["PCCase"] = "PCCase";
354
524
  /** CPU Cooler - Air or liquid cooling for the processor */
355
525
  PartCategory["CPUCooler"] = "CPUCooler";
526
+ /** Case Fans - Additional cooling fans for the case */
527
+ PartCategory["CaseFan"] = "CaseFan";
356
528
  })(PartCategory || (PartCategory = {}));
357
529
 
358
530
  /**
@@ -445,20 +617,47 @@ const useBuildRender = (parts, apiConfig, onLoadStart, options) => {
445
617
  };
446
618
  };
447
619
 
448
- const useSpriteRender = (parts, apiConfig, onLoadStart, options) => {
620
+ const useSpriteRender = (input, apiConfig, onLoadStart, options) => {
449
621
  const [spriteSrc, setSpriteSrc] = useState(null);
450
622
  const [isRenderingSprite, setIsRenderingSprite] = useState(false);
451
623
  const [renderError, setRenderError] = useState(null);
452
624
  const [spriteMetadata, setSpriteMetadata] = useState(null);
453
- const previousPartsRef = useRef(null);
454
- const fetchRenderSprite = useCallback(async (currentParts) => {
625
+ const previousInputRef = useRef(null);
626
+ // Normalize input to SpriteRenderInput format
627
+ const normalizedInput = 'type' in input ? input : { type: 'parts', parts: input };
628
+ const fetchRenderSprite = useCallback(async (currentInput) => {
455
629
  try {
456
630
  setIsRenderingSprite(true);
457
631
  setRenderError(null);
458
632
  onLoadStart?.();
633
+ // Handle share code rendering - uses existing build with proper interactive state
634
+ if (currentInput.type === 'shareCode') {
635
+ const { videoUrl: spriteUrl } = await renderByShareCode(currentInput.shareCode, apiConfig, {
636
+ format: 'sprite',
637
+ profile: currentInput.profile,
638
+ showGrid: currentInput.showGrid,
639
+ cameraOffsetX: currentInput.cameraOffsetX,
640
+ gridSettings: currentInput.gridSettings
641
+ });
642
+ setSpriteSrc((prevSrc) => {
643
+ if (prevSrc && prevSrc.startsWith("blob:")) {
644
+ URL.revokeObjectURL(prevSrc);
645
+ }
646
+ return spriteUrl;
647
+ });
648
+ setSpriteMetadata({ cols: 12, rows: 6, totalFrames: 72 });
649
+ return;
650
+ }
651
+ // Handle parts-based rendering (creates new build)
652
+ const currentParts = currentInput.parts;
459
653
  const mode = options?.mode ?? "async";
460
654
  if (mode === "experimental") {
461
- const response = await renderSpriteExperimental(currentParts, apiConfig);
655
+ const response = await renderSpriteExperimental({
656
+ ...currentParts,
657
+ showGrid: currentInput.showGrid,
658
+ cameraOffsetX: currentInput.cameraOffsetX,
659
+ gridSettings: currentInput.gridSettings,
660
+ }, apiConfig);
462
661
  const objectUrl = URL.createObjectURL(response.sprite);
463
662
  // Clean up previous sprite URL before setting new one
464
663
  setSpriteSrc((prevSrc) => {
@@ -476,7 +675,13 @@ const useSpriteRender = (parts, apiConfig, onLoadStart, options) => {
476
675
  }
477
676
  else {
478
677
  // Async job-based flow: request sprite format and use returned URL
479
- const { videoUrl: spriteUrl } = await renderBuild({ ...currentParts, format: "sprite" }, apiConfig);
678
+ const { videoUrl: spriteUrl } = await renderBuild({
679
+ ...currentParts,
680
+ format: "sprite",
681
+ showGrid: currentInput.showGrid,
682
+ cameraOffsetX: currentInput.cameraOffsetX,
683
+ gridSettings: currentInput.gridSettings,
684
+ }, apiConfig);
480
685
  setSpriteSrc((prevSrc) => {
481
686
  if (prevSrc && prevSrc.startsWith("blob:")) {
482
687
  URL.revokeObjectURL(prevSrc);
@@ -494,15 +699,39 @@ const useSpriteRender = (parts, apiConfig, onLoadStart, options) => {
494
699
  setIsRenderingSprite(false);
495
700
  }
496
701
  }, [apiConfig, onLoadStart, options?.mode]);
497
- // Effect to call API when parts content changes (using custom equality check)
702
+ // Check if inputs are equal
703
+ const areInputsEqual = (a, b) => {
704
+ if (!a)
705
+ return false;
706
+ if (a.type !== b.type)
707
+ return false;
708
+ if (a.type === 'shareCode' && b.type === 'shareCode') {
709
+ // Compare grid settings (shallow comparison of properties)
710
+ const gridSettingsEqual = JSON.stringify(a.gridSettings ?? {}) === JSON.stringify(b.gridSettings ?? {});
711
+ return a.shareCode === b.shareCode &&
712
+ a.profile === b.profile &&
713
+ a.showGrid === b.showGrid &&
714
+ a.cameraOffsetX === b.cameraOffsetX &&
715
+ gridSettingsEqual;
716
+ }
717
+ if (a.type === 'parts' && b.type === 'parts') {
718
+ // Compare grid settings (shallow comparison of properties)
719
+ const gridSettingsEqual = JSON.stringify(a.gridSettings ?? {}) === JSON.stringify(b.gridSettings ?? {});
720
+ return arePartsEqual(a.parts, b.parts) &&
721
+ a.showGrid === b.showGrid &&
722
+ a.cameraOffsetX === b.cameraOffsetX &&
723
+ gridSettingsEqual;
724
+ }
725
+ return false;
726
+ };
727
+ // Effect to call API when input changes
498
728
  useEffect(() => {
499
- const shouldFetch = previousPartsRef.current === null ||
500
- !arePartsEqual(previousPartsRef.current, parts);
729
+ const shouldFetch = !areInputsEqual(previousInputRef.current, normalizedInput);
501
730
  if (shouldFetch) {
502
- previousPartsRef.current = parts;
503
- fetchRenderSprite(parts);
731
+ previousInputRef.current = normalizedInput;
732
+ fetchRenderSprite(normalizedInput);
504
733
  }
505
- }, [parts, fetchRenderSprite]);
734
+ }, [normalizedInput, fetchRenderSprite]);
506
735
  // Cleanup effect for component unmount
507
736
  useEffect(() => {
508
737
  return () => {
@@ -666,7 +895,7 @@ const useZoomPan = ({ displayWidth, displayHeight, minScale = 1, maxScale = 4, }
666
895
  };
667
896
  };
668
897
 
669
- const BuildRender = ({ parts, width, height, size, apiConfig, useSpriteRenderOptions, mouseSensitivity = 0.2, touchSensitivity = 0.2, }) => {
898
+ const BuildRender = ({ parts, shareCode, width, height, size, apiConfig, useSpriteRenderOptions, mouseSensitivity = 0.2, touchSensitivity = 0.2, showGrid, cameraOffsetX, gridSettings, }) => {
670
899
  const canvasRef = useRef(null);
671
900
  const containerRef = useRef(null);
672
901
  const [img, setImg] = useState(null);
@@ -674,8 +903,28 @@ const BuildRender = ({ parts, width, height, size, apiConfig, useSpriteRenderOpt
674
903
  const [bouncingAllowed, setBouncingAllowed] = useState(false);
675
904
  const displayW = width ?? size ?? 300;
676
905
  const displayH = height ?? size ?? 300;
906
+ // Build the render input - prefer shareCode if provided (preserves interactive state like case fan slots)
907
+ const renderInput = useMemo(() => {
908
+ if (shareCode) {
909
+ return {
910
+ type: 'shareCode',
911
+ shareCode,
912
+ profile: parts?.profile,
913
+ showGrid,
914
+ cameraOffsetX,
915
+ gridSettings,
916
+ };
917
+ }
918
+ return {
919
+ type: 'parts',
920
+ parts: parts,
921
+ showGrid,
922
+ cameraOffsetX,
923
+ gridSettings,
924
+ };
925
+ }, [shareCode, parts, showGrid, cameraOffsetX, gridSettings]);
677
926
  // Use custom hook for sprite rendering
678
- const { spriteSrc, isRenderingSprite, renderError, spriteMetadata } = useSpriteRender(parts, apiConfig, undefined, useSpriteRenderOptions);
927
+ const { spriteSrc, isRenderingSprite, renderError, spriteMetadata } = useSpriteRender(renderInput, apiConfig, undefined, useSpriteRenderOptions);
679
928
  const { value: progressValue, isBouncing } = useBouncePatternProgress(bouncingAllowed);
680
929
  const total = spriteMetadata ? spriteMetadata.totalFrames : 72;
681
930
  const cols = spriteMetadata ? spriteMetadata.cols : 12;
@@ -998,5 +1247,5 @@ const BuildRenderVideo = ({ parts, width, height, size, apiConfig, useBuildRende
998
1247
  !hasDragged.current, progressValue: progressValue })] }));
999
1248
  };
1000
1249
 
1001
- export { API_BASE_URL, API_ENDPOINTS, BuildRender, BuildRenderVideo, DragIcon, InstructionTooltip, LoadingErrorOverlay, PartCategory, arePartsEqual, buildApiUrl, buildHeaders, calculateCircularFrame, calculateCircularTime, getAvailableParts, renderBuildExperimental, renderSpriteExperimental, useBouncePatternProgress, useBuildRender, useSpriteRender, useSpriteScrubbing, useVideoScrubbing };
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 };
1002
1251
  //# sourceMappingURL=index.esm.js.map