@buildcores/render-client 1.0.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 +341 -0
- package/dist/BuildRender.d.ts +2 -0
- package/dist/api.d.ts +37 -0
- package/dist/components/DragIcon.d.ts +9 -0
- package/dist/components/InstructionTooltip.d.ts +8 -0
- package/dist/components/LoadingErrorOverlay.d.ts +8 -0
- package/dist/hooks/useBuildRender.d.ts +12 -0
- package/dist/hooks/useProgressOneSecond.d.ts +4 -0
- package/dist/hooks/useVideoScrubbing.d.ts +15 -0
- package/dist/index.d.ts +379 -0
- package/dist/index.esm.js +582 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +597 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +282 -0
- package/package.json +47 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
2
|
+
import { useState, useRef, useCallback, useEffect, createContext, useContext } from 'react';
|
|
3
|
+
|
|
4
|
+
// Helper to extract clientX from mouse or touch events
|
|
5
|
+
const getClientX = (e) => {
|
|
6
|
+
return "touches" in e ? e.touches[0].clientX : e.clientX;
|
|
7
|
+
};
|
|
8
|
+
// Helper to calculate new video time with circular wrapping
|
|
9
|
+
const calculateCircularTime = (startTime, deltaX, sensitivity, duration) => {
|
|
10
|
+
const timeDelta = deltaX * sensitivity;
|
|
11
|
+
let newTime = startTime + timeDelta;
|
|
12
|
+
// Make it circular - wrap around when going past boundaries
|
|
13
|
+
newTime = newTime % duration;
|
|
14
|
+
if (newTime < 0) {
|
|
15
|
+
newTime += duration;
|
|
16
|
+
}
|
|
17
|
+
return newTime;
|
|
18
|
+
};
|
|
19
|
+
const useVideoScrubbing = (videoRef, options = {}) => {
|
|
20
|
+
const { mouseSensitivity = 0.01, touchSensitivity = 0.01 } = options;
|
|
21
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
22
|
+
const [dragStartX, setDragStartX] = useState(0);
|
|
23
|
+
const [dragStartTime, setDragStartTime] = useState(0);
|
|
24
|
+
const hasDragged = useRef(false);
|
|
25
|
+
// Helper to start dragging (common logic for mouse and touch)
|
|
26
|
+
const startDrag = useCallback((clientX, event) => {
|
|
27
|
+
if (!videoRef.current)
|
|
28
|
+
return;
|
|
29
|
+
setIsDragging(true);
|
|
30
|
+
setDragStartX(clientX);
|
|
31
|
+
setDragStartTime(videoRef.current.currentTime);
|
|
32
|
+
hasDragged.current = true;
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
}, [videoRef]);
|
|
35
|
+
// Helper to handle drag movement (common logic for mouse and touch)
|
|
36
|
+
const handleDragMove = useCallback((clientX, sensitivity) => {
|
|
37
|
+
if (!isDragging || !videoRef.current)
|
|
38
|
+
return;
|
|
39
|
+
const deltaX = clientX - dragStartX;
|
|
40
|
+
const duration = videoRef.current.duration || 0;
|
|
41
|
+
if (duration > 0) {
|
|
42
|
+
const newTime = calculateCircularTime(dragStartTime, deltaX, sensitivity, duration);
|
|
43
|
+
videoRef.current.currentTime = newTime;
|
|
44
|
+
}
|
|
45
|
+
}, [isDragging, dragStartX, dragStartTime, videoRef]);
|
|
46
|
+
// Helper to end dragging (common logic for mouse and touch)
|
|
47
|
+
const endDrag = useCallback(() => {
|
|
48
|
+
setIsDragging(false);
|
|
49
|
+
}, []);
|
|
50
|
+
const handleMouseDown = useCallback((e) => {
|
|
51
|
+
startDrag(e.clientX, e.nativeEvent);
|
|
52
|
+
}, [startDrag]);
|
|
53
|
+
const handleTouchStart = useCallback((e) => {
|
|
54
|
+
startDrag(e.touches[0].clientX, e.nativeEvent);
|
|
55
|
+
}, [startDrag]);
|
|
56
|
+
const handleDocumentMouseMove = useCallback((e) => {
|
|
57
|
+
handleDragMove(getClientX(e), mouseSensitivity);
|
|
58
|
+
}, [handleDragMove, mouseSensitivity]);
|
|
59
|
+
const handleDocumentTouchMove = useCallback((e) => {
|
|
60
|
+
handleDragMove(getClientX(e), touchSensitivity);
|
|
61
|
+
}, [handleDragMove, touchSensitivity]);
|
|
62
|
+
const handleDocumentMouseUp = useCallback(() => {
|
|
63
|
+
endDrag();
|
|
64
|
+
}, [endDrag]);
|
|
65
|
+
const handleDocumentTouchEnd = useCallback(() => {
|
|
66
|
+
endDrag();
|
|
67
|
+
}, [endDrag]);
|
|
68
|
+
// Add document-level event listeners when dragging starts
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (isDragging) {
|
|
71
|
+
document.addEventListener("mousemove", handleDocumentMouseMove);
|
|
72
|
+
document.addEventListener("mouseup", handleDocumentMouseUp);
|
|
73
|
+
document.addEventListener("touchmove", handleDocumentTouchMove);
|
|
74
|
+
document.addEventListener("touchend", handleDocumentTouchEnd);
|
|
75
|
+
return () => {
|
|
76
|
+
document.removeEventListener("mousemove", handleDocumentMouseMove);
|
|
77
|
+
document.removeEventListener("mouseup", handleDocumentMouseUp);
|
|
78
|
+
document.removeEventListener("touchmove", handleDocumentTouchMove);
|
|
79
|
+
document.removeEventListener("touchend", handleDocumentTouchEnd);
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}, [
|
|
83
|
+
isDragging,
|
|
84
|
+
handleDocumentMouseMove,
|
|
85
|
+
handleDocumentMouseUp,
|
|
86
|
+
handleDocumentTouchMove,
|
|
87
|
+
handleDocumentTouchEnd,
|
|
88
|
+
]);
|
|
89
|
+
return {
|
|
90
|
+
isDragging,
|
|
91
|
+
handleMouseDown,
|
|
92
|
+
handleTouchStart,
|
|
93
|
+
hasDragged,
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @public
|
|
99
|
+
*/
|
|
100
|
+
const MotionConfigContext = createContext({
|
|
101
|
+
transformPagePoint: (p) => p,
|
|
102
|
+
isStatic: false,
|
|
103
|
+
reducedMotion: "never",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/*#__NO_SIDE_EFFECTS__*/
|
|
107
|
+
const noop = (any) => any;
|
|
108
|
+
|
|
109
|
+
if (process.env.NODE_ENV !== "production") ;
|
|
110
|
+
|
|
111
|
+
function createRenderStep(runNextFrame) {
|
|
112
|
+
/**
|
|
113
|
+
* We create and reuse two queues, one to queue jobs for the current frame
|
|
114
|
+
* and one for the next. We reuse to avoid triggering GC after x frames.
|
|
115
|
+
*/
|
|
116
|
+
let thisFrame = new Set();
|
|
117
|
+
let nextFrame = new Set();
|
|
118
|
+
/**
|
|
119
|
+
* Track whether we're currently processing jobs in this step. This way
|
|
120
|
+
* we can decide whether to schedule new jobs for this frame or next.
|
|
121
|
+
*/
|
|
122
|
+
let isProcessing = false;
|
|
123
|
+
let flushNextFrame = false;
|
|
124
|
+
/**
|
|
125
|
+
* A set of processes which were marked keepAlive when scheduled.
|
|
126
|
+
*/
|
|
127
|
+
const toKeepAlive = new WeakSet();
|
|
128
|
+
let latestFrameData = {
|
|
129
|
+
delta: 0.0,
|
|
130
|
+
timestamp: 0.0,
|
|
131
|
+
isProcessing: false,
|
|
132
|
+
};
|
|
133
|
+
function triggerCallback(callback) {
|
|
134
|
+
if (toKeepAlive.has(callback)) {
|
|
135
|
+
step.schedule(callback);
|
|
136
|
+
runNextFrame();
|
|
137
|
+
}
|
|
138
|
+
callback(latestFrameData);
|
|
139
|
+
}
|
|
140
|
+
const step = {
|
|
141
|
+
/**
|
|
142
|
+
* Schedule a process to run on the next frame.
|
|
143
|
+
*/
|
|
144
|
+
schedule: (callback, keepAlive = false, immediate = false) => {
|
|
145
|
+
const addToCurrentFrame = immediate && isProcessing;
|
|
146
|
+
const queue = addToCurrentFrame ? thisFrame : nextFrame;
|
|
147
|
+
if (keepAlive)
|
|
148
|
+
toKeepAlive.add(callback);
|
|
149
|
+
if (!queue.has(callback))
|
|
150
|
+
queue.add(callback);
|
|
151
|
+
return callback;
|
|
152
|
+
},
|
|
153
|
+
/**
|
|
154
|
+
* Cancel the provided callback from running on the next frame.
|
|
155
|
+
*/
|
|
156
|
+
cancel: (callback) => {
|
|
157
|
+
nextFrame.delete(callback);
|
|
158
|
+
toKeepAlive.delete(callback);
|
|
159
|
+
},
|
|
160
|
+
/**
|
|
161
|
+
* Execute all schedule callbacks.
|
|
162
|
+
*/
|
|
163
|
+
process: (frameData) => {
|
|
164
|
+
latestFrameData = frameData;
|
|
165
|
+
/**
|
|
166
|
+
* If we're already processing we've probably been triggered by a flushSync
|
|
167
|
+
* inside an existing process. Instead of executing, mark flushNextFrame
|
|
168
|
+
* as true and ensure we flush the following frame at the end of this one.
|
|
169
|
+
*/
|
|
170
|
+
if (isProcessing) {
|
|
171
|
+
flushNextFrame = true;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
isProcessing = true;
|
|
175
|
+
[thisFrame, nextFrame] = [nextFrame, thisFrame];
|
|
176
|
+
// Execute this frame
|
|
177
|
+
thisFrame.forEach(triggerCallback);
|
|
178
|
+
// Clear the frame so no callbacks remain. This is to avoid
|
|
179
|
+
// memory leaks should this render step not run for a while.
|
|
180
|
+
thisFrame.clear();
|
|
181
|
+
isProcessing = false;
|
|
182
|
+
if (flushNextFrame) {
|
|
183
|
+
flushNextFrame = false;
|
|
184
|
+
step.process(frameData);
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
return step;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const stepsOrder = [
|
|
192
|
+
"read", // Read
|
|
193
|
+
"resolveKeyframes", // Write/Read/Write/Read
|
|
194
|
+
"update", // Compute
|
|
195
|
+
"preRender", // Compute
|
|
196
|
+
"render", // Write
|
|
197
|
+
"postRender", // Compute
|
|
198
|
+
];
|
|
199
|
+
const maxElapsed = 40;
|
|
200
|
+
function createRenderBatcher(scheduleNextBatch, allowKeepAlive) {
|
|
201
|
+
let runNextFrame = false;
|
|
202
|
+
let useDefaultElapsed = true;
|
|
203
|
+
const state = {
|
|
204
|
+
delta: 0.0,
|
|
205
|
+
timestamp: 0.0,
|
|
206
|
+
isProcessing: false,
|
|
207
|
+
};
|
|
208
|
+
const flagRunNextFrame = () => (runNextFrame = true);
|
|
209
|
+
const steps = stepsOrder.reduce((acc, key) => {
|
|
210
|
+
acc[key] = createRenderStep(flagRunNextFrame);
|
|
211
|
+
return acc;
|
|
212
|
+
}, {});
|
|
213
|
+
const { read, resolveKeyframes, update, preRender, render, postRender } = steps;
|
|
214
|
+
const processBatch = () => {
|
|
215
|
+
const timestamp = performance.now();
|
|
216
|
+
runNextFrame = false;
|
|
217
|
+
state.delta = useDefaultElapsed
|
|
218
|
+
? 1000 / 60
|
|
219
|
+
: Math.max(Math.min(timestamp - state.timestamp, maxElapsed), 1);
|
|
220
|
+
state.timestamp = timestamp;
|
|
221
|
+
state.isProcessing = true;
|
|
222
|
+
// Unrolled render loop for better per-frame performance
|
|
223
|
+
read.process(state);
|
|
224
|
+
resolveKeyframes.process(state);
|
|
225
|
+
update.process(state);
|
|
226
|
+
preRender.process(state);
|
|
227
|
+
render.process(state);
|
|
228
|
+
postRender.process(state);
|
|
229
|
+
state.isProcessing = false;
|
|
230
|
+
if (runNextFrame && allowKeepAlive) {
|
|
231
|
+
useDefaultElapsed = false;
|
|
232
|
+
scheduleNextBatch(processBatch);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
const wake = () => {
|
|
236
|
+
runNextFrame = true;
|
|
237
|
+
useDefaultElapsed = true;
|
|
238
|
+
if (!state.isProcessing) {
|
|
239
|
+
scheduleNextBatch(processBatch);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
const schedule = stepsOrder.reduce((acc, key) => {
|
|
243
|
+
const step = steps[key];
|
|
244
|
+
acc[key] = (process, keepAlive = false, immediate = false) => {
|
|
245
|
+
if (!runNextFrame)
|
|
246
|
+
wake();
|
|
247
|
+
return step.schedule(process, keepAlive, immediate);
|
|
248
|
+
};
|
|
249
|
+
return acc;
|
|
250
|
+
}, {});
|
|
251
|
+
const cancel = (process) => {
|
|
252
|
+
for (let i = 0; i < stepsOrder.length; i++) {
|
|
253
|
+
steps[stepsOrder[i]].cancel(process);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
return { schedule, cancel, state, steps };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const { schedule: frame, cancel: cancelFrame} = createRenderBatcher(typeof requestAnimationFrame !== "undefined" ? requestAnimationFrame : noop, true);
|
|
260
|
+
|
|
261
|
+
function useAnimationFrame(callback) {
|
|
262
|
+
const initialTimestamp = useRef(0);
|
|
263
|
+
const { isStatic } = useContext(MotionConfigContext);
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
if (isStatic)
|
|
266
|
+
return;
|
|
267
|
+
const provideTimeSinceStart = ({ timestamp, delta }) => {
|
|
268
|
+
if (!initialTimestamp.current)
|
|
269
|
+
initialTimestamp.current = timestamp;
|
|
270
|
+
callback(timestamp - initialTimestamp.current, delta);
|
|
271
|
+
};
|
|
272
|
+
frame.update(provideTimeSinceStart, true);
|
|
273
|
+
return () => cancelFrame(provideTimeSinceStart);
|
|
274
|
+
}, [callback]);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function useBouncePatternProgress(enabled = true) {
|
|
278
|
+
const [value, setValue] = useState(0);
|
|
279
|
+
const [isBouncing, setIsBouncing] = useState(false);
|
|
280
|
+
const start = useRef(null);
|
|
281
|
+
useAnimationFrame((t) => {
|
|
282
|
+
if (!enabled) {
|
|
283
|
+
// Reset animation when disabled
|
|
284
|
+
if (start.current !== null) {
|
|
285
|
+
start.current = null;
|
|
286
|
+
setValue(0);
|
|
287
|
+
setIsBouncing(false);
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (start.current === null)
|
|
292
|
+
start.current = t;
|
|
293
|
+
const elapsed = (t - start.current) % 3000; // 3s full cycle
|
|
294
|
+
let progress = 0;
|
|
295
|
+
const bouncing = elapsed < 1000; // Bouncing during first 1 second
|
|
296
|
+
if (elapsed < 500) {
|
|
297
|
+
// 0 → 1
|
|
298
|
+
progress = elapsed / 500;
|
|
299
|
+
}
|
|
300
|
+
else if (elapsed < 1000) {
|
|
301
|
+
// 1 → 0
|
|
302
|
+
progress = 1 - (elapsed - 500) / 500;
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
// Pause at 0 for 2 seconds
|
|
306
|
+
progress = 0;
|
|
307
|
+
}
|
|
308
|
+
setValue(progress);
|
|
309
|
+
setIsBouncing(bouncing);
|
|
310
|
+
});
|
|
311
|
+
return { value, isBouncing };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// API Types
|
|
315
|
+
/**
|
|
316
|
+
* Enum defining all available PC part categories that can be rendered.
|
|
317
|
+
*
|
|
318
|
+
* Each category represents a different type of computer component that can be
|
|
319
|
+
* included in the 3D build visualization.
|
|
320
|
+
*
|
|
321
|
+
* @example
|
|
322
|
+
* ```tsx
|
|
323
|
+
* // All available categories
|
|
324
|
+
* const categories = [
|
|
325
|
+
* PartCategory.CPU, // "CPU"
|
|
326
|
+
* PartCategory.GPU, // "GPU"
|
|
327
|
+
* PartCategory.RAM, // "RAM"
|
|
328
|
+
* PartCategory.Motherboard,// "Motherboard"
|
|
329
|
+
* PartCategory.PSU, // "PSU"
|
|
330
|
+
* PartCategory.Storage, // "Storage"
|
|
331
|
+
* PartCategory.PCCase, // "PCCase"
|
|
332
|
+
* PartCategory.CPUCooler, // "CPUCooler"
|
|
333
|
+
* ];
|
|
334
|
+
* ```
|
|
335
|
+
*/
|
|
336
|
+
var PartCategory;
|
|
337
|
+
(function (PartCategory) {
|
|
338
|
+
/** Central Processing Unit - The main processor */
|
|
339
|
+
PartCategory["CPU"] = "CPU";
|
|
340
|
+
/** Graphics Processing Unit - Video card for rendering */
|
|
341
|
+
PartCategory["GPU"] = "GPU";
|
|
342
|
+
/** Random Access Memory - System memory modules */
|
|
343
|
+
PartCategory["RAM"] = "RAM";
|
|
344
|
+
/** Main circuit board that connects all components */
|
|
345
|
+
PartCategory["Motherboard"] = "Motherboard";
|
|
346
|
+
/** Power Supply Unit - Provides power to all components */
|
|
347
|
+
PartCategory["PSU"] = "PSU";
|
|
348
|
+
/** Storage devices like SSDs, HDDs, NVMe drives */
|
|
349
|
+
PartCategory["Storage"] = "Storage";
|
|
350
|
+
/** PC Case - The enclosure that houses all components */
|
|
351
|
+
PartCategory["PCCase"] = "PCCase";
|
|
352
|
+
/** CPU Cooler - Air or liquid cooling for the processor */
|
|
353
|
+
PartCategory["CPUCooler"] = "CPUCooler";
|
|
354
|
+
})(PartCategory || (PartCategory = {}));
|
|
355
|
+
|
|
356
|
+
// API Configuration
|
|
357
|
+
const API_BASE_URL = "https://squid-app-7aeyk.ondigitalocean.app";
|
|
358
|
+
// API Endpoints
|
|
359
|
+
const API_ENDPOINTS = {
|
|
360
|
+
RENDER_BUILD_EXPERIMENTAL: "/render-build-experimental",
|
|
361
|
+
AVAILABLE_PARTS: "/available-parts",
|
|
362
|
+
};
|
|
363
|
+
// API URL helpers
|
|
364
|
+
const buildApiUrl = (endpoint) => {
|
|
365
|
+
return `${API_BASE_URL}${endpoint}`;
|
|
366
|
+
};
|
|
367
|
+
// API Implementation
|
|
368
|
+
const renderBuildExperimental = async (request) => {
|
|
369
|
+
const response = await fetch(buildApiUrl(API_ENDPOINTS.RENDER_BUILD_EXPERIMENTAL), {
|
|
370
|
+
method: "POST",
|
|
371
|
+
headers: {
|
|
372
|
+
"Content-Type": "application/json",
|
|
373
|
+
},
|
|
374
|
+
body: JSON.stringify(request),
|
|
375
|
+
});
|
|
376
|
+
if (!response.ok) {
|
|
377
|
+
throw new Error(`Render build failed: ${response.status} ${response.statusText}`);
|
|
378
|
+
}
|
|
379
|
+
const video = await response.blob();
|
|
380
|
+
return {
|
|
381
|
+
video,
|
|
382
|
+
metadata: {
|
|
383
|
+
size: video.size,
|
|
384
|
+
format: "video/mp4",
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
};
|
|
388
|
+
const getAvailableParts = async () => {
|
|
389
|
+
const response = await fetch(buildApiUrl(API_ENDPOINTS.AVAILABLE_PARTS), {
|
|
390
|
+
method: "GET",
|
|
391
|
+
headers: {
|
|
392
|
+
"Content-Type": "application/json",
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
if (!response.ok) {
|
|
396
|
+
throw new Error(`Get available parts failed: ${response.status} ${response.statusText}`);
|
|
397
|
+
}
|
|
398
|
+
return response.json();
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Compares two RenderBuildRequest objects for equality by checking if the same IDs
|
|
403
|
+
* are present in each category array, regardless of order.
|
|
404
|
+
*/
|
|
405
|
+
const arePartsEqual = (parts1, parts2) => {
|
|
406
|
+
const categories = Object.values(PartCategory);
|
|
407
|
+
for (const category of categories) {
|
|
408
|
+
const arr1 = parts1.parts[category] || [];
|
|
409
|
+
const arr2 = parts2.parts[category] || [];
|
|
410
|
+
// Check if arrays have the same length
|
|
411
|
+
if (arr1.length !== arr2.length) {
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
// Check if arrays contain the same elements (order doesn't matter)
|
|
415
|
+
const set1 = new Set(arr1);
|
|
416
|
+
const set2 = new Set(arr2);
|
|
417
|
+
if (set1.size !== set2.size) {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
for (const id of set1) {
|
|
421
|
+
if (!set2.has(id)) {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return true;
|
|
427
|
+
};
|
|
428
|
+
const useBuildRender = (parts, onLoadStart) => {
|
|
429
|
+
const [videoSrc, setVideoSrc] = useState(null);
|
|
430
|
+
const [isRenderingBuild, setIsRenderingBuild] = useState(false);
|
|
431
|
+
const [renderError, setRenderError] = useState(null);
|
|
432
|
+
const previousPartsRef = useRef(null);
|
|
433
|
+
const fetchRenderBuild = useCallback(async (currentParts) => {
|
|
434
|
+
try {
|
|
435
|
+
setIsRenderingBuild(true);
|
|
436
|
+
setRenderError(null);
|
|
437
|
+
onLoadStart?.();
|
|
438
|
+
const response = await renderBuildExperimental(currentParts);
|
|
439
|
+
const objectUrl = URL.createObjectURL(response.video);
|
|
440
|
+
// Clean up previous video URL before setting new one
|
|
441
|
+
setVideoSrc((prevSrc) => {
|
|
442
|
+
if (prevSrc) {
|
|
443
|
+
URL.revokeObjectURL(prevSrc);
|
|
444
|
+
}
|
|
445
|
+
return objectUrl;
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
setRenderError(error instanceof Error ? error.message : "Failed to render build");
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
setIsRenderingBuild(false);
|
|
453
|
+
}
|
|
454
|
+
}, [onLoadStart]);
|
|
455
|
+
// Effect to call API when parts content changes (using custom equality check)
|
|
456
|
+
useEffect(() => {
|
|
457
|
+
const shouldFetch = previousPartsRef.current === null ||
|
|
458
|
+
!arePartsEqual(previousPartsRef.current, parts);
|
|
459
|
+
if (shouldFetch) {
|
|
460
|
+
previousPartsRef.current = parts;
|
|
461
|
+
fetchRenderBuild(parts);
|
|
462
|
+
}
|
|
463
|
+
}, [parts, fetchRenderBuild]);
|
|
464
|
+
// Cleanup effect for component unmount
|
|
465
|
+
useEffect(() => {
|
|
466
|
+
return () => {
|
|
467
|
+
if (videoSrc) {
|
|
468
|
+
URL.revokeObjectURL(videoSrc);
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
}, [videoSrc]);
|
|
472
|
+
return {
|
|
473
|
+
videoSrc,
|
|
474
|
+
isRenderingBuild,
|
|
475
|
+
renderError,
|
|
476
|
+
};
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const LoadingErrorOverlay = ({ isVisible, renderError, size, }) => {
|
|
480
|
+
if (!isVisible)
|
|
481
|
+
return null;
|
|
482
|
+
return (jsx("div", { style: {
|
|
483
|
+
position: "absolute",
|
|
484
|
+
top: 0,
|
|
485
|
+
left: 0,
|
|
486
|
+
right: 0,
|
|
487
|
+
bottom: 0,
|
|
488
|
+
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
|
489
|
+
display: "flex",
|
|
490
|
+
flexDirection: "column",
|
|
491
|
+
alignItems: "center",
|
|
492
|
+
justifyContent: "center",
|
|
493
|
+
color: "white",
|
|
494
|
+
zIndex: 10,
|
|
495
|
+
}, children: renderError ? (jsxs(Fragment, { children: [jsx("div", { style: { marginBottom: "20px", fontSize: "18px" }, children: "Render Failed" }), jsx("div", { style: {
|
|
496
|
+
fontSize: "14px",
|
|
497
|
+
textAlign: "center",
|
|
498
|
+
maxWidth: size * 0.8,
|
|
499
|
+
}, children: renderError })] })) : (jsx(Fragment, { children: jsx("div", { style: { marginBottom: "20px", fontSize: "18px" }, children: "Loading Build..." }) })) }));
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
const DragIcon = ({ width = 24, height = 24, className, style, ...props }) => {
|
|
503
|
+
return (jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", id: "Layer_1", width: width, height: height, "data-name": "Layer 1", viewBox: "0 0 24 24", className: className, style: style, ...props, children: [jsx("defs", { children: jsx("style", { children: ".cls-1{fill:none;stroke:currentColor;stroke-miterlimit:10;stroke-width:1.91px}" }) }), jsx("path", { d: "m11.05 22.5-5.14-5.14a2 2 0 0 1-.59-1.43 2 2 0 0 1 2-2 2 2 0 0 1 1.43.59l1.32 1.32V6.38a2 2 0 0 1 1.74-2 1.89 1.89 0 0 1 1.52.56 1.87 1.87 0 0 1 .56 1.34V12l5 .72a1.91 1.91 0 0 1 1.64 1.89 17.18 17.18 0 0 1-1.82 7.71l-.09.18M19.64 7.23l2.86-2.87-2.86-2.86M15.82 4.36h6.68M4.36 7.23 1.5 4.36 4.36 1.5M8.18 4.36H1.5", className: "cls-1" })] }));
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
const InstructionTooltip = ({ isVisible, progressValue, instructionIcon, }) => {
|
|
507
|
+
if (!isVisible) {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
return (jsx("div", { style: {
|
|
511
|
+
position: "absolute",
|
|
512
|
+
top: "50%",
|
|
513
|
+
left: "50%",
|
|
514
|
+
transform: `translate(-50%, -50%) translateX(${progressValue * 100}px)`,
|
|
515
|
+
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
|
516
|
+
color: "white",
|
|
517
|
+
padding: "12px",
|
|
518
|
+
borderRadius: "8px",
|
|
519
|
+
pointerEvents: "none",
|
|
520
|
+
zIndex: 5,
|
|
521
|
+
display: "flex",
|
|
522
|
+
alignItems: "center",
|
|
523
|
+
justifyContent: "center",
|
|
524
|
+
}, children: instructionIcon ? (jsx("img", { src: instructionIcon, alt: "drag to view 360", style: {
|
|
525
|
+
width: "24px",
|
|
526
|
+
height: "24px",
|
|
527
|
+
filter: "invert(1)", // Makes the icon white
|
|
528
|
+
} })) : (jsx(DragIcon, { width: 24, height: 24, style: {
|
|
529
|
+
color: "white",
|
|
530
|
+
} })) }));
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
const BuildRender = ({ parts, size, mouseSensitivity = 0.01, touchSensitivity = 0.01, }) => {
|
|
534
|
+
const videoRef = useRef(null);
|
|
535
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
536
|
+
const [bouncingAllowed, setBouncingAllowed] = useState(false);
|
|
537
|
+
// Use custom hook for build rendering
|
|
538
|
+
const { videoSrc, isRenderingBuild, renderError } = useBuildRender(parts);
|
|
539
|
+
const { value: progressValue, isBouncing } = useBouncePatternProgress(bouncingAllowed);
|
|
540
|
+
const { isDragging, handleMouseDown, handleTouchStart, hasDragged } = useVideoScrubbing(videoRef, {
|
|
541
|
+
mouseSensitivity,
|
|
542
|
+
touchSensitivity,
|
|
543
|
+
});
|
|
544
|
+
const handleLoadStartInternal = useCallback(() => {
|
|
545
|
+
setIsLoading(true);
|
|
546
|
+
setBouncingAllowed(false);
|
|
547
|
+
}, []);
|
|
548
|
+
const handleCanPlayInternal = useCallback(() => {
|
|
549
|
+
setIsLoading(false);
|
|
550
|
+
// Start bouncing animation after delay
|
|
551
|
+
setTimeout(() => {
|
|
552
|
+
setBouncingAllowed(true);
|
|
553
|
+
}, 2000);
|
|
554
|
+
}, []);
|
|
555
|
+
useEffect(() => {
|
|
556
|
+
if (hasDragged.current || !videoRef.current)
|
|
557
|
+
return;
|
|
558
|
+
const duration = videoRef.current.duration;
|
|
559
|
+
if (!isFinite(duration))
|
|
560
|
+
return;
|
|
561
|
+
const time = calculateCircularTime(0, progressValue, 0.5, duration);
|
|
562
|
+
if (isFinite(time)) {
|
|
563
|
+
videoRef.current.currentTime = time;
|
|
564
|
+
}
|
|
565
|
+
}, [progressValue, hasDragged]);
|
|
566
|
+
return (jsxs("div", { style: { position: "relative", width: size, height: size }, children: [videoSrc && (jsx("video", { ref: videoRef, src: videoSrc, width: size, height: size, autoPlay: true, preload: "metadata", muted: true, playsInline: true, onMouseDown: handleMouseDown, onTouchStart: handleTouchStart, onLoadStart: handleLoadStartInternal, onCanPlay: handleCanPlayInternal, onLoadedData: () => {
|
|
567
|
+
if (videoRef.current) {
|
|
568
|
+
videoRef.current.pause();
|
|
569
|
+
}
|
|
570
|
+
}, style: {
|
|
571
|
+
cursor: isDragging ? "grabbing" : "grab",
|
|
572
|
+
touchAction: "none", // Prevents default touch behaviors like scrolling
|
|
573
|
+
display: "block",
|
|
574
|
+
}, children: "Your browser does not support the video tag." }, videoSrc)), jsx(LoadingErrorOverlay, { isVisible: isLoading || isRenderingBuild || !!renderError, renderError: renderError || undefined, size: size }), jsx(InstructionTooltip, { isVisible: !isLoading &&
|
|
575
|
+
!isRenderingBuild &&
|
|
576
|
+
!renderError &&
|
|
577
|
+
isBouncing &&
|
|
578
|
+
!hasDragged.current, progressValue: progressValue })] }));
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
export { API_BASE_URL, API_ENDPOINTS, BuildRender, DragIcon, InstructionTooltip, LoadingErrorOverlay, PartCategory, arePartsEqual, buildApiUrl, calculateCircularTime, getAvailableParts, renderBuildExperimental, useBouncePatternProgress, useBuildRender, useVideoScrubbing };
|
|
582
|
+
//# sourceMappingURL=index.esm.js.map
|