@brandonlukas/luminar 0.1.0 → 0.2.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 +40 -0
- package/dist/assets/{index-CCp_I16V.css → index-BHq4NTuF.css} +1 -1
- package/dist/assets/{index-BTqubGOw.js → index-Dn5q-yAE.js} +7 -7
- package/dist/index.html +2 -2
- package/dist/vector-field.json +3692 -3380
- package/package.json +2 -2
- package/public/vector-field.json +3692 -3380
- package/src/main.ts +207 -0
- package/src/style.css +55 -0
package/src/main.ts
CHANGED
|
@@ -111,6 +111,16 @@ let fieldStatusEl: HTMLElement | null = null
|
|
|
111
111
|
let fieldTransform = { scale: 1, offsetX: 0, offsetY: 0 }
|
|
112
112
|
let fieldDistControlHandle: SliderHandle | null = null
|
|
113
113
|
|
|
114
|
+
let mediaRecorder: MediaRecorder | null = null
|
|
115
|
+
let recordedChunks: Blob[] = []
|
|
116
|
+
let isRecording = false
|
|
117
|
+
let recordingStartTime = 0
|
|
118
|
+
let recordingDuration = 5 // seconds
|
|
119
|
+
let recordingResolution: 'current' | '1080p' | '1440p' | '4k' = 'current'
|
|
120
|
+
let recordButton: HTMLButtonElement | null = null
|
|
121
|
+
let recordStatus: HTMLDivElement | null = null
|
|
122
|
+
let originalCanvasSize: { width: number; height: number } | null = null
|
|
123
|
+
|
|
114
124
|
initParticles()
|
|
115
125
|
resize()
|
|
116
126
|
window.addEventListener('resize', resize)
|
|
@@ -224,6 +234,15 @@ function animate(timestamp: number) {
|
|
|
224
234
|
geometry.attributes.color.needsUpdate = true
|
|
225
235
|
|
|
226
236
|
composer.render()
|
|
237
|
+
|
|
238
|
+
if (isRecording) {
|
|
239
|
+
const elapsed = (performance.now() - recordingStartTime) / 1000
|
|
240
|
+
updateRecordingStatus(elapsed)
|
|
241
|
+
if (elapsed >= recordingDuration) {
|
|
242
|
+
stopRecording()
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
227
246
|
requestAnimationFrame(animate)
|
|
228
247
|
}
|
|
229
248
|
|
|
@@ -337,6 +356,61 @@ function createControls() {
|
|
|
337
356
|
panel.appendChild(advancedToggle)
|
|
338
357
|
panel.appendChild(advancedSection)
|
|
339
358
|
|
|
359
|
+
// Recording controls
|
|
360
|
+
const recordingSection = document.createElement('div')
|
|
361
|
+
recordingSection.className = 'controls__section'
|
|
362
|
+
recordingSection.innerHTML = '<div class="controls__subtitle">Export (WebM)</div>'
|
|
363
|
+
|
|
364
|
+
const resolutionRow = document.createElement('label')
|
|
365
|
+
resolutionRow.className = 'controls__row'
|
|
366
|
+
const resolutionLabel = document.createElement('span')
|
|
367
|
+
resolutionLabel.textContent = 'Resolution'
|
|
368
|
+
const resolutionSelect = document.createElement('select')
|
|
369
|
+
resolutionSelect.className = 'controls__select'
|
|
370
|
+
resolutionSelect.innerHTML = '<option value="current">Current window</option><option value="1080p">1080p (Full HD)</option><option value="1440p">1440p (2K)</option><option value="4k">4K (Ultra HD)</option>'
|
|
371
|
+
resolutionSelect.value = recordingResolution
|
|
372
|
+
resolutionSelect.addEventListener('change', (e) => {
|
|
373
|
+
recordingResolution = (e.target as HTMLSelectElement).value as typeof recordingResolution
|
|
374
|
+
})
|
|
375
|
+
resolutionRow.appendChild(resolutionLabel)
|
|
376
|
+
resolutionRow.appendChild(resolutionSelect)
|
|
377
|
+
recordingSection.appendChild(resolutionRow)
|
|
378
|
+
|
|
379
|
+
const durationRow = document.createElement('label')
|
|
380
|
+
durationRow.className = 'controls__row'
|
|
381
|
+
const durationLabel = document.createElement('span')
|
|
382
|
+
durationLabel.textContent = 'Duration'
|
|
383
|
+
const durationSelect = document.createElement('select')
|
|
384
|
+
durationSelect.className = 'controls__select'
|
|
385
|
+
durationSelect.innerHTML = '<option value="3">3 seconds</option><option value="5">5 seconds</option><option value="10">10 seconds</option><option value="15">15 seconds</option>'
|
|
386
|
+
durationSelect.value = String(recordingDuration)
|
|
387
|
+
durationSelect.addEventListener('change', (e) => {
|
|
388
|
+
recordingDuration = parseInt((e.target as HTMLSelectElement).value)
|
|
389
|
+
})
|
|
390
|
+
durationRow.appendChild(durationLabel)
|
|
391
|
+
durationRow.appendChild(durationSelect)
|
|
392
|
+
recordingSection.appendChild(durationRow)
|
|
393
|
+
|
|
394
|
+
recordButton = document.createElement('button')
|
|
395
|
+
recordButton.type = 'button'
|
|
396
|
+
recordButton.className = 'controls__button controls__button--record'
|
|
397
|
+
recordButton.textContent = '⏺ Start recording'
|
|
398
|
+
recordButton.addEventListener('click', () => {
|
|
399
|
+
if (isRecording) {
|
|
400
|
+
stopRecording()
|
|
401
|
+
} else {
|
|
402
|
+
startRecording()
|
|
403
|
+
}
|
|
404
|
+
})
|
|
405
|
+
recordingSection.appendChild(recordButton)
|
|
406
|
+
|
|
407
|
+
recordStatus = document.createElement('div')
|
|
408
|
+
recordStatus.className = 'controls__status'
|
|
409
|
+
recordStatus.style.display = 'none'
|
|
410
|
+
recordingSection.appendChild(recordStatus)
|
|
411
|
+
|
|
412
|
+
panel.appendChild(recordingSection)
|
|
413
|
+
|
|
340
414
|
const resetBtn = document.createElement('button')
|
|
341
415
|
resetBtn.type = 'button'
|
|
342
416
|
resetBtn.className = 'controls__button'
|
|
@@ -497,6 +571,139 @@ function resizeParticleBuffers() {
|
|
|
497
571
|
initParticles()
|
|
498
572
|
}
|
|
499
573
|
|
|
574
|
+
function startRecording() {
|
|
575
|
+
if (isRecording) return
|
|
576
|
+
|
|
577
|
+
try {
|
|
578
|
+
const canvas = renderer.domElement
|
|
579
|
+
|
|
580
|
+
// Determine recording resolution
|
|
581
|
+
let recordWidth: number, recordHeight: number
|
|
582
|
+
const currentAspect = canvas.width / canvas.height
|
|
583
|
+
|
|
584
|
+
if (recordingResolution === 'current') {
|
|
585
|
+
recordWidth = canvas.width
|
|
586
|
+
recordHeight = canvas.height
|
|
587
|
+
} else {
|
|
588
|
+
// Save original size and resize for recording
|
|
589
|
+
originalCanvasSize = { width: canvas.width, height: canvas.height }
|
|
590
|
+
|
|
591
|
+
switch (recordingResolution) {
|
|
592
|
+
case '1080p':
|
|
593
|
+
recordHeight = 1080
|
|
594
|
+
recordWidth = Math.round(recordHeight * currentAspect)
|
|
595
|
+
break
|
|
596
|
+
case '1440p':
|
|
597
|
+
recordHeight = 1440
|
|
598
|
+
recordWidth = Math.round(recordHeight * currentAspect)
|
|
599
|
+
break
|
|
600
|
+
case '4k':
|
|
601
|
+
recordHeight = 2160
|
|
602
|
+
recordWidth = Math.round(recordHeight * currentAspect)
|
|
603
|
+
break
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Resize canvas for recording
|
|
607
|
+
renderer.setSize(recordWidth, recordHeight, false)
|
|
608
|
+
composer.setSize(recordWidth, recordHeight)
|
|
609
|
+
bloomPass.setSize(recordWidth, recordHeight)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const stream = canvas.captureStream(60) // 60 fps
|
|
613
|
+
|
|
614
|
+
// Calculate bitrate based on resolution (higher res = higher bitrate)
|
|
615
|
+
const pixelCount = recordWidth * recordHeight
|
|
616
|
+
const bitrate = Math.min(25000000, Math.max(8000000, pixelCount * 4)) // 4 bits per pixel, capped at 25 Mbps
|
|
617
|
+
|
|
618
|
+
recordedChunks = []
|
|
619
|
+
mediaRecorder = new MediaRecorder(stream, {
|
|
620
|
+
mimeType: 'video/webm;codecs=vp9',
|
|
621
|
+
videoBitsPerSecond: bitrate,
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
mediaRecorder.ondataavailable = (event) => {
|
|
625
|
+
if (event.data.size > 0) {
|
|
626
|
+
recordedChunks.push(event.data)
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
mediaRecorder.onstop = () => {
|
|
631
|
+
// Restore original canvas size if we resized
|
|
632
|
+
if (originalCanvasSize) {
|
|
633
|
+
renderer.setSize(originalCanvasSize.width, originalCanvasSize.height, false)
|
|
634
|
+
composer.setSize(originalCanvasSize.width, originalCanvasSize.height)
|
|
635
|
+
bloomPass.setSize(originalCanvasSize.width, originalCanvasSize.height)
|
|
636
|
+
originalCanvasSize = null
|
|
637
|
+
resize() // Trigger full resize to ensure proper state
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const blob = new Blob(recordedChunks, { type: 'video/webm' })
|
|
641
|
+
const url = URL.createObjectURL(blob)
|
|
642
|
+
const a = document.createElement('a')
|
|
643
|
+
a.href = url
|
|
644
|
+
a.download = `luminar-${recordWidth}x${recordHeight}-${Date.now()}.webm`
|
|
645
|
+
a.click()
|
|
646
|
+
URL.revokeObjectURL(url)
|
|
647
|
+
|
|
648
|
+
if (recordStatus) {
|
|
649
|
+
recordStatus.textContent = 'Recording complete! Download started.'
|
|
650
|
+
setTimeout(() => {
|
|
651
|
+
if (recordStatus) recordStatus.style.display = 'none'
|
|
652
|
+
}, 3000)
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
mediaRecorder.start()
|
|
657
|
+
isRecording = true
|
|
658
|
+
recordingStartTime = performance.now()
|
|
659
|
+
|
|
660
|
+
if (recordButton) {
|
|
661
|
+
recordButton.textContent = '⏹ Stop recording'
|
|
662
|
+
recordButton.style.opacity = '1'
|
|
663
|
+
}
|
|
664
|
+
if (recordStatus) {
|
|
665
|
+
recordStatus.style.display = 'block'
|
|
666
|
+
recordStatus.textContent = `Recording at ${recordWidth}x${recordHeight} (${(bitrate / 1000000).toFixed(0)} Mbps): 0.0s / ${recordingDuration}s`
|
|
667
|
+
}
|
|
668
|
+
} catch (error) {
|
|
669
|
+
console.error('Failed to start recording:', error)
|
|
670
|
+
// Restore size if recording failed
|
|
671
|
+
if (originalCanvasSize) {
|
|
672
|
+
renderer.setSize(originalCanvasSize.width, originalCanvasSize.height, false)
|
|
673
|
+
composer.setSize(originalCanvasSize.width, originalCanvasSize.height)
|
|
674
|
+
bloomPass.setSize(originalCanvasSize.width, originalCanvasSize.height)
|
|
675
|
+
originalCanvasSize = null
|
|
676
|
+
resize()
|
|
677
|
+
}
|
|
678
|
+
if (recordStatus) {
|
|
679
|
+
recordStatus.style.display = 'block'
|
|
680
|
+
recordStatus.textContent = 'Recording not supported in this browser.'
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function stopRecording() {
|
|
686
|
+
if (!isRecording || !mediaRecorder) return
|
|
687
|
+
|
|
688
|
+
isRecording = false
|
|
689
|
+
mediaRecorder.stop()
|
|
690
|
+
mediaRecorder = null
|
|
691
|
+
|
|
692
|
+
if (recordButton) {
|
|
693
|
+
recordButton.textContent = '▶ Start recording'
|
|
694
|
+
recordButton.style.opacity = '1'
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function updateRecordingStatus(elapsed: number) {
|
|
699
|
+
if (recordStatus) {
|
|
700
|
+
const current = elapsed.toFixed(1)
|
|
701
|
+
const canvas = renderer.domElement
|
|
702
|
+
const bitrate = Math.min(25000000, Math.max(8000000, canvas.width * canvas.height * 4))
|
|
703
|
+
recordStatus.textContent = `Recording at ${canvas.width}x${canvas.height} (${(bitrate / 1000000).toFixed(0)} Mbps): ${current}s / ${recordingDuration}s`
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
500
707
|
async function loadVectorField() {
|
|
501
708
|
try {
|
|
502
709
|
const res = await fetch('/vector-field.json', { cache: 'no-store' })
|
package/src/style.css
CHANGED
|
@@ -133,4 +133,59 @@ canvas {
|
|
|
133
133
|
|
|
134
134
|
.controls__button:active {
|
|
135
135
|
transform: translateY(1px);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.controls__button--record {
|
|
139
|
+
background: linear-gradient(120deg, rgba(255, 80, 120, 0.22), rgba(200, 80, 120, 0.14));
|
|
140
|
+
border-color: rgba(255, 120, 150, 0.4);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.controls__button--record:hover {
|
|
144
|
+
border-color: rgba(255, 140, 170, 0.8);
|
|
145
|
+
box-shadow: 0 0 18px rgba(255, 120, 150, 0.3);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.controls__section {
|
|
149
|
+
margin-top: 12px;
|
|
150
|
+
padding-top: 12px;
|
|
151
|
+
border-top: 1px solid rgba(120, 180, 255, 0.15);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.controls__subtitle {
|
|
155
|
+
font-size: 11px;
|
|
156
|
+
text-transform: uppercase;
|
|
157
|
+
letter-spacing: 0.1em;
|
|
158
|
+
margin-bottom: 8px;
|
|
159
|
+
color: #9fb7ff;
|
|
160
|
+
opacity: 0.85;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.controls__status {
|
|
164
|
+
font-size: 11px;
|
|
165
|
+
margin-top: 8px;
|
|
166
|
+
padding: 6px 8px;
|
|
167
|
+
background: rgba(100, 180, 255, 0.1);
|
|
168
|
+
border: 1px solid rgba(120, 180, 255, 0.2);
|
|
169
|
+
border-radius: 6px;
|
|
170
|
+
color: #b3d4ff;
|
|
171
|
+
text-align: center;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.controls__select {
|
|
175
|
+
width: 100%;
|
|
176
|
+
padding: 4px 6px;
|
|
177
|
+
background: rgba(20, 30, 50, 0.5);
|
|
178
|
+
border: 1px solid rgba(120, 180, 255, 0.25);
|
|
179
|
+
border-radius: 6px;
|
|
180
|
+
color: #dfe8ff;
|
|
181
|
+
font-size: 11px;
|
|
182
|
+
cursor: pointer;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.controls__select:hover {
|
|
186
|
+
border-color: rgba(140, 200, 255, 0.5);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.controls__advanced {
|
|
190
|
+
margin-top: 8px;
|
|
136
191
|
}
|