@brandonlukas/luminar 0.1.1 → 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/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
  }