@brandonlukas/luminar 0.1.1 → 0.2.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/README.md +86 -32
- package/bin/luminar.mjs +22 -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/dist/vector-field.json +3692 -3380
- package/package.json +1 -1
- package/public/vector-field.json +3692 -3380
- 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 +243 -486
- 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 +130 -0
- package/dist/assets/index-BTqubGOw.js +0 -4151
- package/dist/assets/index-CCp_I16V.css +0 -1
|
@@ -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;
|
|
@@ -133,4 +179,88 @@ canvas {
|
|
|
133
179
|
|
|
134
180
|
.controls__button:active {
|
|
135
181
|
transform: translateY(1px);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.controls__button--record {
|
|
185
|
+
background: linear-gradient(120deg, rgba(255, 80, 120, 0.22), rgba(200, 80, 120, 0.14));
|
|
186
|
+
border-color: rgba(255, 120, 150, 0.4);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.controls__button--record:hover {
|
|
190
|
+
border-color: rgba(255, 140, 170, 0.8);
|
|
191
|
+
box-shadow: 0 0 18px rgba(255, 120, 150, 0.3);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.controls__section {
|
|
195
|
+
margin-top: 12px;
|
|
196
|
+
padding-top: 12px;
|
|
197
|
+
border-top: 1px solid rgba(120, 180, 255, 0.15);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.controls__subtitle {
|
|
201
|
+
font-size: 11px;
|
|
202
|
+
text-transform: uppercase;
|
|
203
|
+
letter-spacing: 0.1em;
|
|
204
|
+
margin-bottom: 8px;
|
|
205
|
+
color: #9fb7ff;
|
|
206
|
+
opacity: 0.85;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.controls__status {
|
|
210
|
+
font-size: 11px;
|
|
211
|
+
margin-top: 8px;
|
|
212
|
+
padding: 6px 8px;
|
|
213
|
+
background: rgba(100, 180, 255, 0.1);
|
|
214
|
+
border: 1px solid rgba(120, 180, 255, 0.2);
|
|
215
|
+
border-radius: 6px;
|
|
216
|
+
color: #b3d4ff;
|
|
217
|
+
text-align: center;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.controls__select {
|
|
221
|
+
width: 100%;
|
|
222
|
+
padding: 4px 6px;
|
|
223
|
+
background: rgba(20, 30, 50, 0.5);
|
|
224
|
+
border: 1px solid rgba(120, 180, 255, 0.25);
|
|
225
|
+
border-radius: 6px;
|
|
226
|
+
color: #dfe8ff;
|
|
227
|
+
font-size: 11px;
|
|
228
|
+
cursor: pointer;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.controls__select:hover {
|
|
232
|
+
border-color: rgba(140, 200, 255, 0.5);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.controls__advanced {
|
|
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);
|
|
136
266
|
}
|