@brandonlukas/luminar 0.2.0 → 0.2.2
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 +87 -50
- package/bin/luminar.mjs +45 -36
- package/dist/assets/index-BTv18fJQ.css +1 -0
- package/dist/assets/index-DqXax9_P.js +4181 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/lib/constants.ts +37 -0
- package/src/lib/csv-parser.ts +24 -0
- package/src/lib/types.ts +26 -0
- package/src/main.ts +240 -690
- package/src/modules/controls.ts +424 -0
- package/src/modules/field-loader.ts +71 -0
- package/src/modules/particle-system.ts +329 -0
- package/src/modules/recording.ts +227 -0
- package/src/style.css +75 -0
- package/dist/assets/index-BHq4NTuF.css +0 -1
- package/dist/assets/index-Dn5q-yAE.js +0 -4151
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { BufferAttribute, BufferGeometry } from 'three'
|
|
2
|
+
import { SimplexNoise } from 'three/examples/jsm/math/SimplexNoise.js'
|
|
3
|
+
import type { VectorSample, VectorDatum, ColorPreset, ParticleParams, FieldTransform } from '../lib/types'
|
|
4
|
+
import { WORLD_EXTENT, FLOW_SCALE, SPEED_TO_GLOW, JITTER, DEFAULT_COLOR_PRESET, COLOR_PRESETS } from '../lib/constants'
|
|
5
|
+
|
|
6
|
+
export class ParticleSystem {
|
|
7
|
+
// Particle data buffers
|
|
8
|
+
private positions: Float32Array
|
|
9
|
+
private colors: Float32Array
|
|
10
|
+
private lifetimes: Float32Array
|
|
11
|
+
|
|
12
|
+
// Field data and transformation
|
|
13
|
+
private fieldData: VectorDatum[] | null = null
|
|
14
|
+
private fieldTransform: FieldTransform = { scale: 1, offsetX: 0, offsetY: 0 }
|
|
15
|
+
|
|
16
|
+
// Spatial grid for O(1) field lookups
|
|
17
|
+
private grid: Map<string, VectorDatum[]> = new Map()
|
|
18
|
+
private gridCellSize = 0.1
|
|
19
|
+
|
|
20
|
+
// Three.js resources
|
|
21
|
+
public geometry: BufferGeometry
|
|
22
|
+
private params: ParticleParams
|
|
23
|
+
|
|
24
|
+
// View and rendering state
|
|
25
|
+
private viewOffsetX: number
|
|
26
|
+
private activePalette: ColorPreset | null = null
|
|
27
|
+
|
|
28
|
+
// Noise generation
|
|
29
|
+
private noise: SimplexNoise
|
|
30
|
+
private readonly noiseScale = 0.9
|
|
31
|
+
private readonly noiseTimeScale = 0.15
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
geometry: BufferGeometry,
|
|
35
|
+
params: ParticleParams,
|
|
36
|
+
) {
|
|
37
|
+
this.geometry = geometry
|
|
38
|
+
this.params = params
|
|
39
|
+
this.viewOffsetX = 0
|
|
40
|
+
this.positions = new Float32Array(params.particleCount * 3)
|
|
41
|
+
this.colors = new Float32Array(params.particleCount * 3)
|
|
42
|
+
this.lifetimes = new Float32Array(params.particleCount)
|
|
43
|
+
this.noise = new SimplexNoise()
|
|
44
|
+
|
|
45
|
+
this.geometry.setAttribute('position', new BufferAttribute(this.positions, 3))
|
|
46
|
+
this.geometry.setAttribute('color', new BufferAttribute(this.colors, 3))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
init() {
|
|
50
|
+
for (let i = 0; i < this.params.particleCount; i += 1) {
|
|
51
|
+
this.resetParticle(i)
|
|
52
|
+
}
|
|
53
|
+
this.updateBuffers()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setFieldData(data: VectorDatum[] | null, transform: FieldTransform) {
|
|
57
|
+
this.fieldData = data
|
|
58
|
+
this.fieldTransform = transform
|
|
59
|
+
this.buildSpatialGrid()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
hasFieldData(): boolean {
|
|
63
|
+
return this.fieldData !== null && this.fieldData.length > 0
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setViewOffset(offsetX: number) {
|
|
67
|
+
if (this.viewOffsetX !== offsetX) {
|
|
68
|
+
this.viewOffsetX = offsetX
|
|
69
|
+
// Reseed particles when offset changes to prevent gaps
|
|
70
|
+
for (let i = 0; i < this.params.particleCount; i += 1) {
|
|
71
|
+
this.resetParticle(i)
|
|
72
|
+
}
|
|
73
|
+
this.updateBuffers()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
update(dt: number, palette: ColorPreset) {
|
|
78
|
+
const time = performance.now() * 0.001
|
|
79
|
+
this.activePalette = palette
|
|
80
|
+
const noiseEnabled = this.params.noiseStrength > 0
|
|
81
|
+
const speedMultiplier = FLOW_SCALE * this.params.speed * dt
|
|
82
|
+
const jitterRange = JITTER * dt
|
|
83
|
+
|
|
84
|
+
for (let i = 0; i < this.params.particleCount; i += 1) {
|
|
85
|
+
const i3 = i * 3
|
|
86
|
+
let x = this.positions[i3]
|
|
87
|
+
let y = this.positions[i3 + 1]
|
|
88
|
+
|
|
89
|
+
const v = this.sampleField(x, y, time)
|
|
90
|
+
if (!v) {
|
|
91
|
+
this.resetParticle(i)
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (noiseEnabled) {
|
|
96
|
+
this.applyNoise(v, x, y, time)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
x += v.x * speedMultiplier + this.randomRange(-jitterRange, jitterRange)
|
|
100
|
+
y += v.y * speedMultiplier + this.randomRange(-jitterRange, jitterRange)
|
|
101
|
+
|
|
102
|
+
const speed = Math.hypot(v.x, v.y)
|
|
103
|
+
const glow = Math.min(1, speed * SPEED_TO_GLOW)
|
|
104
|
+
|
|
105
|
+
this.applyColor(i3, glow, palette, false)
|
|
106
|
+
|
|
107
|
+
this.lifetimes[i] -= dt
|
|
108
|
+
|
|
109
|
+
if (this.shouldResetParticle(i, x, y)) {
|
|
110
|
+
this.resetParticle(i)
|
|
111
|
+
} else {
|
|
112
|
+
this.positions[i3] = x
|
|
113
|
+
this.positions[i3 + 1] = y
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this.updateBuffers()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
resizeBuffers(newCount: number) {
|
|
121
|
+
this.params.particleCount = newCount
|
|
122
|
+
this.positions = new Float32Array(newCount * 3)
|
|
123
|
+
this.colors = new Float32Array(newCount * 3)
|
|
124
|
+
this.lifetimes = new Float32Array(newCount)
|
|
125
|
+
this.geometry.setAttribute('position', new BufferAttribute(this.positions, 3))
|
|
126
|
+
this.geometry.setAttribute('color', new BufferAttribute(this.colors, 3))
|
|
127
|
+
this.init()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
reseedLifetimes() {
|
|
131
|
+
for (let i = 0; i < this.params.particleCount; i += 1) {
|
|
132
|
+
this.lifetimes[i] = this.randomRange(this.params.lifeMin, this.params.lifeMax)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private shouldResetParticle(i: number, x: number, y: number): boolean {
|
|
137
|
+
return (
|
|
138
|
+
this.lifetimes[i] <= 0 ||
|
|
139
|
+
Math.abs(x - this.viewOffsetX) > WORLD_EXTENT ||
|
|
140
|
+
Math.abs(y) > WORLD_EXTENT
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private applyNoise(velocity: VectorSample, x: number, y: number, time: number) {
|
|
145
|
+
const nX = this.noise.noise3d(x * this.noiseScale, y * this.noiseScale, time * this.noiseTimeScale)
|
|
146
|
+
const nY = this.noise.noise3d((x + 10) * this.noiseScale, (y + 10) * this.noiseScale, time * this.noiseTimeScale)
|
|
147
|
+
velocity.x += nX * this.params.noiseStrength
|
|
148
|
+
velocity.y += nY * this.params.noiseStrength
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private resetParticle(i: number) {
|
|
152
|
+
const i3 = i * 3
|
|
153
|
+
const palette = this.activePalette ?? this.getActiveColorPreset()
|
|
154
|
+
|
|
155
|
+
if (this.hasFieldData()) {
|
|
156
|
+
this.resetParticleWithinField(i3)
|
|
157
|
+
} else {
|
|
158
|
+
this.resetParticleRandomly(i3)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
this.lifetimes[i] = this.randomRange(this.params.lifeMin, this.params.lifeMax)
|
|
162
|
+
const glow = 0.4 + Math.random() * 0.2
|
|
163
|
+
this.applyColor(i3, glow, palette, true)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private resetParticleWithinField(i3: number) {
|
|
167
|
+
const randomPoint = this.fieldData![Math.floor(Math.random() * this.fieldData!.length)]
|
|
168
|
+
const jitterRange = this.params.fieldValidDistance * 0.3
|
|
169
|
+
const dataX = randomPoint.x + this.randomRange(-jitterRange, jitterRange) / this.fieldTransform.scale
|
|
170
|
+
const dataY = randomPoint.y + this.randomRange(-jitterRange, jitterRange) / this.fieldTransform.scale
|
|
171
|
+
|
|
172
|
+
this.positions[i3] = this.dataToWorldX(dataX)
|
|
173
|
+
this.positions[i3 + 1] = this.dataToWorldY(dataY)
|
|
174
|
+
this.positions[i3 + 2] = 0
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private resetParticleRandomly(i3: number) {
|
|
178
|
+
this.positions[i3] = this.randomRange(-WORLD_EXTENT, WORLD_EXTENT) + this.viewOffsetX
|
|
179
|
+
this.positions[i3 + 1] = this.randomRange(-WORLD_EXTENT, WORLD_EXTENT)
|
|
180
|
+
this.positions[i3 + 2] = 0
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private dataToWorldX(dataX: number): number {
|
|
184
|
+
return dataX * this.fieldTransform.scale + this.fieldTransform.offsetX + this.viewOffsetX
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private dataToWorldY(dataY: number): number {
|
|
188
|
+
return dataY * this.fieldTransform.scale + this.fieldTransform.offsetY
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private worldToDataX(worldX: number): number {
|
|
192
|
+
return (worldX - this.viewOffsetX - this.fieldTransform.offsetX) / this.fieldTransform.scale
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private worldToDataY(worldY: number): number {
|
|
196
|
+
return (worldY - this.fieldTransform.offsetY) / this.fieldTransform.scale
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private buildSpatialGrid() {
|
|
200
|
+
this.grid.clear()
|
|
201
|
+
|
|
202
|
+
if (!this.fieldData || this.fieldData.length === 0) {
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Find bounds
|
|
207
|
+
let minX = this.fieldData[0].x
|
|
208
|
+
let maxX = this.fieldData[0].x
|
|
209
|
+
let minY = this.fieldData[0].y
|
|
210
|
+
let maxY = this.fieldData[0].y
|
|
211
|
+
|
|
212
|
+
for (const d of this.fieldData) {
|
|
213
|
+
if (d.x < minX) minX = d.x
|
|
214
|
+
if (d.x > maxX) maxX = d.x
|
|
215
|
+
if (d.y < minY) minY = d.y
|
|
216
|
+
if (d.y > maxY) maxY = d.y
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Auto-adjust cell size based on data density
|
|
220
|
+
const width = maxX - minX
|
|
221
|
+
const height = maxY - minY
|
|
222
|
+
const avgDim = (width + height) / 2
|
|
223
|
+
const targetCellsPerDim = Math.ceil(Math.sqrt(this.fieldData.length))
|
|
224
|
+
this.gridCellSize = Math.max(0.01, avgDim / targetCellsPerDim)
|
|
225
|
+
|
|
226
|
+
// Populate grid
|
|
227
|
+
for (const datum of this.fieldData) {
|
|
228
|
+
const key = this.getGridKey(datum.x, datum.y)
|
|
229
|
+
const cell = this.grid.get(key)
|
|
230
|
+
if (cell) {
|
|
231
|
+
cell.push(datum)
|
|
232
|
+
} else {
|
|
233
|
+
this.grid.set(key, [datum])
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private getGridKey(x: number, y: number): string {
|
|
239
|
+
const cellX = Math.floor(x / this.gridCellSize)
|
|
240
|
+
const cellY = Math.floor(y / this.gridCellSize)
|
|
241
|
+
return `${cellX},${cellY}`
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private sampleField(x: number, y: number, _time: number): VectorSample | null {
|
|
245
|
+
if (!this.hasFieldData()) {
|
|
246
|
+
return { x: 1, y: 0 }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const dataX = this.worldToDataX(x)
|
|
250
|
+
const dataY = this.worldToDataY(y)
|
|
251
|
+
const nearest = this.findNearestFieldPoint(dataX, dataY)
|
|
252
|
+
|
|
253
|
+
if (!nearest) {
|
|
254
|
+
return null
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
x: nearest.dx * this.fieldTransform.scale,
|
|
259
|
+
y: nearest.dy * this.fieldTransform.scale,
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private findNearestFieldPoint(dataX: number, dataY: number): VectorDatum | null {
|
|
264
|
+
let nearest: VectorDatum | null = null
|
|
265
|
+
let bestDistSq = Number.MAX_VALUE
|
|
266
|
+
const thresholdSq = Math.pow(this.params.fieldValidDistance / this.fieldTransform.scale, 2)
|
|
267
|
+
|
|
268
|
+
// Search 3x3 grid of cells (current + 8 neighbors)
|
|
269
|
+
const cellX = Math.floor(dataX / this.gridCellSize)
|
|
270
|
+
const cellY = Math.floor(dataY / this.gridCellSize)
|
|
271
|
+
|
|
272
|
+
for (let dx = -1; dx <= 1; dx += 1) {
|
|
273
|
+
for (let dy = -1; dy <= 1; dy += 1) {
|
|
274
|
+
const cell = this.grid.get(`${cellX + dx},${cellY + dy}`)
|
|
275
|
+
if (!cell) continue
|
|
276
|
+
|
|
277
|
+
for (const d of cell) {
|
|
278
|
+
const diffX = d.x - dataX
|
|
279
|
+
const diffY = d.y - dataY
|
|
280
|
+
const distSq = diffX * diffX + diffY * diffY
|
|
281
|
+
if (distSq < bestDistSq) {
|
|
282
|
+
bestDistSq = distSq
|
|
283
|
+
nearest = d
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return bestDistSq <= thresholdSq ? nearest : null
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private applyColor(i3: number, glow: number, palette: ColorPreset, isSpawn: boolean) {
|
|
293
|
+
const clampedGlow = Math.min(1, Math.max(0, glow))
|
|
294
|
+
|
|
295
|
+
// Preserve legacy look for the default palette
|
|
296
|
+
if (palette.key === DEFAULT_COLOR_PRESET) {
|
|
297
|
+
if (isSpawn) {
|
|
298
|
+
this.colors[i3] = 0.6 * clampedGlow
|
|
299
|
+
this.colors[i3 + 1] = 0.25 * clampedGlow
|
|
300
|
+
this.colors[i3 + 2] = 0.9 * clampedGlow
|
|
301
|
+
} else {
|
|
302
|
+
this.colors[i3] = 0.35 + clampedGlow * 0.9
|
|
303
|
+
this.colors[i3 + 1] = 0.18 + clampedGlow * 0.45
|
|
304
|
+
this.colors[i3 + 2] = 0.6 + clampedGlow * 0.35
|
|
305
|
+
}
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const brightness = isSpawn ? clampedGlow : 0.35 + clampedGlow * 0.65
|
|
310
|
+
const [r, g, b] = palette.rgb
|
|
311
|
+
this.colors[i3] = r * brightness
|
|
312
|
+
this.colors[i3 + 1] = g * brightness
|
|
313
|
+
this.colors[i3 + 2] = b * brightness
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
private getActiveColorPreset(): ColorPreset {
|
|
317
|
+
return COLOR_PRESETS.find((preset) => preset.key === this.params.colorPresetA) ?? COLOR_PRESETS[0]
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private randomRange(min: number, max: number) {
|
|
321
|
+
return min + Math.random() * (max - min)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private updateBuffers() {
|
|
325
|
+
this.geometry.attributes.position.needsUpdate = true
|
|
326
|
+
this.geometry.attributes.color.needsUpdate = true
|
|
327
|
+
this.geometry.computeBoundingSphere()
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { WebGLRenderer } from 'three'
|
|
2
|
+
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'
|
|
3
|
+
import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'
|
|
4
|
+
|
|
5
|
+
export class RecordingManager {
|
|
6
|
+
private mediaRecorder: MediaRecorder | null = null
|
|
7
|
+
private recordedChunks: Blob[] = []
|
|
8
|
+
private isRecording = false
|
|
9
|
+
private recordingStartTime = 0
|
|
10
|
+
private recordingDuration = 5
|
|
11
|
+
private recordingResolution: 'current' | '1080p' | '1440p' | '4k' = 'current'
|
|
12
|
+
private originalCanvasSize: { width: number; height: number } | null = null
|
|
13
|
+
private recordButton: HTMLButtonElement | null = null
|
|
14
|
+
private recordStatus: HTMLDivElement | null = null
|
|
15
|
+
private renderer: WebGLRenderer
|
|
16
|
+
private composer: EffectComposer
|
|
17
|
+
private bloomPass: UnrealBloomPass
|
|
18
|
+
private onResize: () => void
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
renderer: WebGLRenderer,
|
|
22
|
+
composer: EffectComposer,
|
|
23
|
+
bloomPass: UnrealBloomPass,
|
|
24
|
+
onResize: () => void,
|
|
25
|
+
) {
|
|
26
|
+
this.renderer = renderer
|
|
27
|
+
this.composer = composer
|
|
28
|
+
this.bloomPass = bloomPass
|
|
29
|
+
this.onResize = onResize
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
createControls(container: HTMLElement) {
|
|
33
|
+
const recordingSection = document.createElement('div')
|
|
34
|
+
recordingSection.className = 'controls__section'
|
|
35
|
+
recordingSection.innerHTML = '<div class="controls__subtitle">Export (WebM)</div>'
|
|
36
|
+
|
|
37
|
+
// Resolution selector
|
|
38
|
+
const resolutionRow = document.createElement('label')
|
|
39
|
+
resolutionRow.className = 'controls__row'
|
|
40
|
+
const resolutionLabel = document.createElement('span')
|
|
41
|
+
resolutionLabel.textContent = 'Resolution'
|
|
42
|
+
const resolutionSelect = document.createElement('select')
|
|
43
|
+
resolutionSelect.className = 'controls__select'
|
|
44
|
+
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>'
|
|
45
|
+
resolutionSelect.value = this.recordingResolution
|
|
46
|
+
resolutionSelect.addEventListener('change', (e) => {
|
|
47
|
+
this.recordingResolution = (e.target as HTMLSelectElement).value as typeof this.recordingResolution
|
|
48
|
+
})
|
|
49
|
+
resolutionRow.appendChild(resolutionLabel)
|
|
50
|
+
resolutionRow.appendChild(resolutionSelect)
|
|
51
|
+
recordingSection.appendChild(resolutionRow)
|
|
52
|
+
|
|
53
|
+
// Duration selector
|
|
54
|
+
const durationRow = document.createElement('label')
|
|
55
|
+
durationRow.className = 'controls__row'
|
|
56
|
+
const durationLabel = document.createElement('span')
|
|
57
|
+
durationLabel.textContent = 'Duration'
|
|
58
|
+
const durationSelect = document.createElement('select')
|
|
59
|
+
durationSelect.className = 'controls__select'
|
|
60
|
+
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>'
|
|
61
|
+
durationSelect.value = String(this.recordingDuration)
|
|
62
|
+
durationSelect.addEventListener('change', (e) => {
|
|
63
|
+
this.recordingDuration = parseInt((e.target as HTMLSelectElement).value)
|
|
64
|
+
})
|
|
65
|
+
durationRow.appendChild(durationLabel)
|
|
66
|
+
durationRow.appendChild(durationSelect)
|
|
67
|
+
recordingSection.appendChild(durationRow)
|
|
68
|
+
|
|
69
|
+
// Record button
|
|
70
|
+
this.recordButton = document.createElement('button')
|
|
71
|
+
this.recordButton.type = 'button'
|
|
72
|
+
this.recordButton.className = 'controls__button controls__button--record'
|
|
73
|
+
this.recordButton.textContent = '⏺ Start recording'
|
|
74
|
+
this.recordButton.addEventListener('click', () => {
|
|
75
|
+
if (this.isRecording) {
|
|
76
|
+
this.stop()
|
|
77
|
+
} else {
|
|
78
|
+
this.start()
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
recordingSection.appendChild(this.recordButton)
|
|
82
|
+
|
|
83
|
+
// Status
|
|
84
|
+
this.recordStatus = document.createElement('div')
|
|
85
|
+
this.recordStatus.className = 'controls__status'
|
|
86
|
+
this.recordStatus.style.display = 'none'
|
|
87
|
+
recordingSection.appendChild(this.recordStatus)
|
|
88
|
+
|
|
89
|
+
container.appendChild(recordingSection)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
update() {
|
|
93
|
+
if (this.isRecording) {
|
|
94
|
+
const elapsed = (performance.now() - this.recordingStartTime) / 1000
|
|
95
|
+
this.updateStatus(elapsed)
|
|
96
|
+
if (elapsed >= this.recordingDuration) {
|
|
97
|
+
this.stop()
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private start() {
|
|
103
|
+
if (this.isRecording) return
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const canvas = this.renderer.domElement
|
|
107
|
+
|
|
108
|
+
let recordWidth: number, recordHeight: number
|
|
109
|
+
const currentAspect = canvas.width / canvas.height
|
|
110
|
+
|
|
111
|
+
if (this.recordingResolution === 'current') {
|
|
112
|
+
recordWidth = canvas.width
|
|
113
|
+
recordHeight = canvas.height
|
|
114
|
+
} else {
|
|
115
|
+
this.originalCanvasSize = { width: canvas.width, height: canvas.height }
|
|
116
|
+
|
|
117
|
+
switch (this.recordingResolution) {
|
|
118
|
+
case '1080p':
|
|
119
|
+
recordHeight = 1080
|
|
120
|
+
recordWidth = Math.round(recordHeight * currentAspect)
|
|
121
|
+
break
|
|
122
|
+
case '1440p':
|
|
123
|
+
recordHeight = 1440
|
|
124
|
+
recordWidth = Math.round(recordHeight * currentAspect)
|
|
125
|
+
break
|
|
126
|
+
case '4k':
|
|
127
|
+
recordHeight = 2160
|
|
128
|
+
recordWidth = Math.round(recordHeight * currentAspect)
|
|
129
|
+
break
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this.renderer.setSize(recordWidth, recordHeight, false)
|
|
133
|
+
this.composer.setSize(recordWidth, recordHeight)
|
|
134
|
+
this.bloomPass.setSize(recordWidth, recordHeight)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const stream = canvas.captureStream(60)
|
|
138
|
+
const pixelCount = recordWidth * recordHeight
|
|
139
|
+
const bitrate = Math.min(25000000, Math.max(8000000, pixelCount * 4))
|
|
140
|
+
|
|
141
|
+
this.recordedChunks = []
|
|
142
|
+
this.mediaRecorder = new MediaRecorder(stream, {
|
|
143
|
+
mimeType: 'video/webm;codecs=vp9',
|
|
144
|
+
videoBitsPerSecond: bitrate,
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
this.mediaRecorder.ondataavailable = (event) => {
|
|
148
|
+
if (event.data.size > 0) {
|
|
149
|
+
this.recordedChunks.push(event.data)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this.mediaRecorder.onstop = () => {
|
|
154
|
+
if (this.originalCanvasSize) {
|
|
155
|
+
this.renderer.setSize(this.originalCanvasSize.width, this.originalCanvasSize.height, false)
|
|
156
|
+
this.composer.setSize(this.originalCanvasSize.width, this.originalCanvasSize.height)
|
|
157
|
+
this.bloomPass.setSize(this.originalCanvasSize.width, this.originalCanvasSize.height)
|
|
158
|
+
this.originalCanvasSize = null
|
|
159
|
+
this.onResize()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const blob = new Blob(this.recordedChunks, { type: 'video/webm' })
|
|
163
|
+
const url = URL.createObjectURL(blob)
|
|
164
|
+
const a = document.createElement('a')
|
|
165
|
+
a.href = url
|
|
166
|
+
a.download = `luminar-${recordWidth}x${recordHeight}-${Date.now()}.webm`
|
|
167
|
+
a.click()
|
|
168
|
+
URL.revokeObjectURL(url)
|
|
169
|
+
|
|
170
|
+
if (this.recordStatus) {
|
|
171
|
+
this.recordStatus.textContent = 'Recording complete! Download started.'
|
|
172
|
+
setTimeout(() => {
|
|
173
|
+
if (this.recordStatus) this.recordStatus.style.display = 'none'
|
|
174
|
+
}, 3000)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
this.mediaRecorder.start()
|
|
179
|
+
this.isRecording = true
|
|
180
|
+
this.recordingStartTime = performance.now()
|
|
181
|
+
|
|
182
|
+
if (this.recordButton) {
|
|
183
|
+
this.recordButton.textContent = '⏹ Stop recording'
|
|
184
|
+
this.recordButton.style.opacity = '1'
|
|
185
|
+
}
|
|
186
|
+
if (this.recordStatus) {
|
|
187
|
+
this.recordStatus.style.display = 'block'
|
|
188
|
+
this.recordStatus.textContent = `Recording at ${recordWidth}x${recordHeight} (${(bitrate / 1000000).toFixed(0)} Mbps): 0.0s / ${this.recordingDuration}s`
|
|
189
|
+
}
|
|
190
|
+
} catch (error) {
|
|
191
|
+
console.error('Failed to start recording:', error)
|
|
192
|
+
if (this.originalCanvasSize) {
|
|
193
|
+
this.renderer.setSize(this.originalCanvasSize.width, this.originalCanvasSize.height, false)
|
|
194
|
+
this.composer.setSize(this.originalCanvasSize.width, this.originalCanvasSize.height)
|
|
195
|
+
this.bloomPass.setSize(this.originalCanvasSize.width, this.originalCanvasSize.height)
|
|
196
|
+
this.originalCanvasSize = null
|
|
197
|
+
this.onResize()
|
|
198
|
+
}
|
|
199
|
+
if (this.recordStatus) {
|
|
200
|
+
this.recordStatus.style.display = 'block'
|
|
201
|
+
this.recordStatus.textContent = 'Recording not supported in this browser.'
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private stop() {
|
|
207
|
+
if (!this.isRecording || !this.mediaRecorder) return
|
|
208
|
+
|
|
209
|
+
this.isRecording = false
|
|
210
|
+
this.mediaRecorder.stop()
|
|
211
|
+
this.mediaRecorder = null
|
|
212
|
+
|
|
213
|
+
if (this.recordButton) {
|
|
214
|
+
this.recordButton.textContent = '▶ Start recording'
|
|
215
|
+
this.recordButton.style.opacity = '1'
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private updateStatus(elapsed: number) {
|
|
220
|
+
if (this.recordStatus) {
|
|
221
|
+
const current = elapsed.toFixed(1)
|
|
222
|
+
const canvas = this.renderer.domElement
|
|
223
|
+
const bitrate = Math.min(25000000, Math.max(8000000, canvas.width * canvas.height * 4))
|
|
224
|
+
this.recordStatus.textContent = `Recording at ${canvas.width}x${canvas.height} (${(bitrate / 1000000).toFixed(0)} Mbps): ${current}s / ${this.recordingDuration}s`
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
package/src/style.css
CHANGED
|
@@ -90,6 +90,52 @@ canvas {
|
|
|
90
90
|
color: #9fb7ff;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
.controls__header {
|
|
94
|
+
display: flex;
|
|
95
|
+
justify-content: space-between;
|
|
96
|
+
align-items: baseline;
|
|
97
|
+
margin-bottom: 10px;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.controls__toggle {
|
|
101
|
+
background: linear-gradient(120deg, rgba(120, 180, 255, 0.12), rgba(80, 120, 200, 0.08));
|
|
102
|
+
border: 1px solid rgba(120, 180, 255, 0.3);
|
|
103
|
+
color: #9fb7ff;
|
|
104
|
+
font-size: 14px;
|
|
105
|
+
font-weight: 600;
|
|
106
|
+
cursor: pointer;
|
|
107
|
+
padding: 6px 10px;
|
|
108
|
+
margin: 0 0 0 8px;
|
|
109
|
+
line-height: 1;
|
|
110
|
+
display: inline-block;
|
|
111
|
+
border-radius: 6px;
|
|
112
|
+
transition: all 120ms ease;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.controls__toggle:hover {
|
|
116
|
+
color: #dfe8ff;
|
|
117
|
+
border-color: rgba(140, 200, 255, 0.6);
|
|
118
|
+
box-shadow: 0 0 12px rgba(120, 180, 255, 0.2);
|
|
119
|
+
background: linear-gradient(120deg, rgba(120, 180, 255, 0.18), rgba(80, 120, 200, 0.12));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.controls__toggle:active {
|
|
123
|
+
transform: translateY(1px);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.controls--collapsed {
|
|
127
|
+
width: auto !important;
|
|
128
|
+
padding: 10px 12px !important;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.controls--collapsed .controls__header {
|
|
132
|
+
margin-bottom: 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.controls--collapsed> :not(.controls__header) {
|
|
136
|
+
display: none !important;
|
|
137
|
+
}
|
|
138
|
+
|
|
93
139
|
.controls__row {
|
|
94
140
|
display: grid;
|
|
95
141
|
grid-template-columns: 1fr 1fr auto;
|
|
@@ -188,4 +234,33 @@ canvas {
|
|
|
188
234
|
|
|
189
235
|
.controls__advanced {
|
|
190
236
|
margin-top: 8px;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.drop-overlay {
|
|
240
|
+
position: fixed;
|
|
241
|
+
display: flex;
|
|
242
|
+
align-items: center;
|
|
243
|
+
justify-content: center;
|
|
244
|
+
color: #d7e2ff;
|
|
245
|
+
font-size: 18px;
|
|
246
|
+
letter-spacing: 0.02em;
|
|
247
|
+
z-index: 20;
|
|
248
|
+
pointer-events: none;
|
|
249
|
+
backdrop-filter: blur(4px);
|
|
250
|
+
font-weight: 600;
|
|
251
|
+
inset: 0;
|
|
252
|
+
width: 50%;
|
|
253
|
+
background: rgba(80, 140, 255, 0.2);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
.drop-overlay--left {
|
|
257
|
+
left: 0;
|
|
258
|
+
right: auto;
|
|
259
|
+
border-right: 3px solid rgba(120, 180, 255, 0.4);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.drop-overlay--right {
|
|
263
|
+
left: auto;
|
|
264
|
+
right: 0;
|
|
265
|
+
border-left: 3px solid rgba(120, 180, 255, 0.4);
|
|
191
266
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
@import "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600&display=swap";:root{color:#e8f0ff;background-color:#02040a;font-family:Space Grotesk,Segoe UI,system-ui,sans-serif}*{box-sizing:border-box}body{background:radial-gradient(circle at 20% 20%,#508cff1f,#0000 35%),radial-gradient(circle at 80% 10%,#b464ff1f,#0000 30%),radial-gradient(circle at 50% 80%,#3cc8b424,#0000 32%),#02040a;min-height:100vh;margin:0;overflow:hidden}#app{position:fixed;inset:0;overflow:hidden}canvas{filter:saturate(1.05);width:100%;height:100%;display:block}.hud{color:#dfe8ff;letter-spacing:.08em;text-transform:uppercase;pointer-events:none;mix-blend-mode:screen;text-shadow:0 0 12px #6eaaff4d;position:absolute;top:18px;left:18px}.hud .title{font-size:16px;font-weight:600}.hud .subtitle{opacity:.7;letter-spacing:.04em;margin-top:4px;font-size:12px;font-weight:400}.hud .status{opacity:.8;letter-spacing:.03em;color:#9fb7ff;margin-top:6px;font-size:11px;font-weight:400}.controls{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);color:#dfe8ff;pointer-events:auto;background:#060a16b3;border:1px solid #78b4ff40;border-radius:12px;width:240px;padding:14px 14px 10px;position:absolute;top:18px;right:18px;box-shadow:0 12px 30px #00000059,0 0 24px #64a0ff1f}.controls__title{text-transform:uppercase;letter-spacing:.1em;color:#9fb7ff;margin-bottom:10px;font-size:12px}.controls__row{color:#e8f0ff;grid-template-columns:1fr 1fr auto;align-items:center;gap:8px;margin-bottom:8px;font-size:12px;display:grid}.controls__row input[type=range]{accent-color:#7cc4ff;width:100%}.controls__value{font-variant-numeric:tabular-nums;color:#9fb7ff;text-align:right;min-width:44px}.controls__button{color:#dfe8ff;letter-spacing:.04em;cursor:pointer;background:linear-gradient(120deg,#78b4ff29,#5078c81a);border:1px solid #78b4ff59;border-radius:10px;width:100%;margin-top:6px;padding:8px 10px;font-size:12px;transition:border-color .12s,transform .12s,box-shadow .2s}.controls__button:hover{border-color:#8cc8ffb3;box-shadow:0 0 18px #78b4ff40}.controls__button:active{transform:translateY(1px)}.controls__button--record{background:linear-gradient(120deg,#ff507838,#c8507824);border-color:#ff789666}.controls__button--record:hover{border-color:#ff8caacc;box-shadow:0 0 18px #ff78964d}.controls__section{border-top:1px solid #78b4ff26;margin-top:12px;padding-top:12px}.controls__subtitle{text-transform:uppercase;letter-spacing:.1em;color:#9fb7ff;opacity:.85;margin-bottom:8px;font-size:11px}.controls__status{color:#b3d4ff;text-align:center;background:#64b4ff1a;border:1px solid #78b4ff33;border-radius:6px;margin-top:8px;padding:6px 8px;font-size:11px}.controls__select{color:#dfe8ff;cursor:pointer;background:#141e3280;border:1px solid #78b4ff40;border-radius:6px;width:100%;padding:4px 6px;font-size:11px}.controls__select:hover{border-color:#8cc8ff80}.controls__advanced{margin-top:8px}
|