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