create-definedmotion 0.1.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/bin/index.js +3 -0
- package/package.json +37 -0
- package/src/cli.js +100 -0
- package/template/.editorconfig +9 -0
- package/template/.prettierignore +6 -0
- package/template/.prettierrc.yaml +10 -0
- package/template/_gitignore +10 -0
- package/template/build/entitlements.mac.plist +12 -0
- package/template/build/icon.icns +0 -0
- package/template/build/icon.ico +0 -0
- package/template/build/icon.png +0 -0
- package/template/electron-builder.yml +43 -0
- package/template/electron.vite.config.ts +50 -0
- package/template/eslint.config.mjs +24 -0
- package/template/package-lock.json +10299 -0
- package/template/package.json +64 -0
- package/template/resources/icon.png +0 -0
- package/template/src/assets/audio/fadeSound.mp3 +0 -0
- package/template/src/assets/audio/fadeSound2.mp3 +0 -0
- package/template/src/assets/audio/interstellar.mp3 +0 -0
- package/template/src/assets/audio/keyboard1.mp3 +0 -0
- package/template/src/assets/audio/keyboard2.mp3 +0 -0
- package/template/src/assets/audio/keyboard3.mp3 +0 -0
- package/template/src/assets/audio/tick_sound.mp3 +0 -0
- package/template/src/assets/base.css +67 -0
- package/template/src/assets/electron.svg +10 -0
- package/template/src/assets/fonts/Geo-Regular.woff +0 -0
- package/template/src/assets/fonts/Montserrat-Italic-VariableFont_wght.woff2 +0 -0
- package/template/src/assets/fonts/Montserrat-Medium.ttf +0 -0
- package/template/src/assets/fonts/Montserrat-Medium.woff +0 -0
- package/template/src/assets/fonts/Montserrat-VariableFont_wght.woff2 +0 -0
- package/template/src/assets/fonts/glitch.ttf +0 -0
- package/template/src/assets/hdri/indoor1.hdr +0 -0
- package/template/src/assets/hdri/metro1.hdr +0 -0
- package/template/src/assets/hdri/outdoor1.hdr +0 -0
- package/template/src/assets/hdri/photo-studio1.hdr +0 -0
- package/template/src/assets/hdri/photo-studio2.hdr +0 -0
- package/template/src/assets/hdri/photo-studio3.hdr +0 -0
- package/template/src/assets/objects/keyboardScene/ibm-keyboard.glb +0 -0
- package/template/src/assets/wavy-lines.svg +25 -0
- package/template/src/entry.ts +20 -0
- package/template/src/example_scenes/alternativesScene.ts +88 -0
- package/template/src/example_scenes/dependencyScene.ts +116 -0
- package/template/src/example_scenes/fourierMachineScene.ts +108 -0
- package/template/src/example_scenes/fourierSeriesScene.ts +678 -0
- package/template/src/example_scenes/keyboardScene.ts +447 -0
- package/template/src/example_scenes/surfaceScene.ts +88 -0
- package/template/src/example_scenes/tutorials/easy1.ts +59 -0
- package/template/src/example_scenes/tutorials/easy2.ts +141 -0
- package/template/src/example_scenes/tutorials/easy3.ts +133 -0
- package/template/src/example_scenes/tutorials/medium1.ts +154 -0
- package/template/src/example_scenes/vectorField.ts +209 -0
- package/template/src/example_scenes/visulizingFunctions.ts +246 -0
- package/template/src/main/index.ts +101 -0
- package/template/src/main/rendering.ts +219 -0
- package/template/src/main/storage.ts +35 -0
- package/template/src/preload/index.d.ts +8 -0
- package/template/src/preload/index.ts +36 -0
- package/template/src/renderer/index.html +17 -0
- package/template/src/renderer/src/App.svelte +130 -0
- package/template/src/renderer/src/app.css +24 -0
- package/template/src/renderer/src/env.d.ts +2 -0
- package/template/src/renderer/src/lib/animation/animations.ts +214 -0
- package/template/src/renderer/src/lib/animation/captureCanvas.ts +85 -0
- package/template/src/renderer/src/lib/animation/helpers.ts +7 -0
- package/template/src/renderer/src/lib/animation/interpolations.ts +155 -0
- package/template/src/renderer/src/lib/animation/protocols.ts +79 -0
- package/template/src/renderer/src/lib/audio/loader.ts +104 -0
- package/template/src/renderer/src/lib/fonts/Roboto_Regular.json +1 -0
- package/template/src/renderer/src/lib/fonts/montserrat-medium.json +1 -0
- package/template/src/renderer/src/lib/fonts/montserrat.json +1 -0
- package/template/src/renderer/src/lib/general/helpers.ts +77 -0
- package/template/src/renderer/src/lib/general/onDestory.ts +10 -0
- package/template/src/renderer/src/lib/mathHelpers/vectors.ts +18 -0
- package/template/src/renderer/src/lib/rendering/bumpMaps/noise.ts +84 -0
- package/template/src/renderer/src/lib/rendering/helpers.ts +35 -0
- package/template/src/renderer/src/lib/rendering/lighting3d.ts +387 -0
- package/template/src/renderer/src/lib/rendering/materials.ts +6 -0
- package/template/src/renderer/src/lib/rendering/objects/import.ts +148 -0
- package/template/src/renderer/src/lib/rendering/objects2d.ts +489 -0
- package/template/src/renderer/src/lib/rendering/objects3d.ts +89 -0
- package/template/src/renderer/src/lib/rendering/protocols.ts +21 -0
- package/template/src/renderer/src/lib/rendering/setup.ts +71 -0
- package/template/src/renderer/src/lib/rendering/svg/drawing.ts +213 -0
- package/template/src/renderer/src/lib/rendering/svg/parsing.ts +717 -0
- package/template/src/renderer/src/lib/rendering/svg/rastered.ts +42 -0
- package/template/src/renderer/src/lib/rendering/svgObjects.ts +1137 -0
- package/template/src/renderer/src/lib/scene/helpers.ts +89 -0
- package/template/src/renderer/src/lib/scene/sceneClass.ts +648 -0
- package/template/src/renderer/src/lib/shaders/background_gradient/frag.glsl +12 -0
- package/template/src/renderer/src/lib/shaders/background_gradient/vert.glsl +6 -0
- package/template/src/renderer/src/lib/shaders/hdri_blur/frag.glsl +45 -0
- package/template/src/renderer/src/lib/shaders/hdri_blur/vert.glsl +5 -0
- package/template/src/renderer/src/main.ts +9 -0
- package/template/svelte.config.mjs +7 -0
- package/template/tsconfig.json +4 -0
- package/template/tsconfig.node.json +10 -0
- package/template/tsconfig.web.json +32 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { COLORS } from '../renderer/src/lib/rendering/helpers'
|
|
2
|
+
import {
|
|
3
|
+
addBackgroundGradient,
|
|
4
|
+
addHDRI,
|
|
5
|
+
addSceneLighting,
|
|
6
|
+
HDRIs,
|
|
7
|
+
loadHDRIData
|
|
8
|
+
} from '../renderer/src/lib/rendering/lighting3d'
|
|
9
|
+
import { AnimatedScene, HotReloadSetting, SpaceSetting } from '../renderer/src/lib/scene/sceneClass'
|
|
10
|
+
import * as THREE from 'three'
|
|
11
|
+
import { MeshLine, MeshLineMaterial } from 'three.meshline'
|
|
12
|
+
import { linspace } from '../renderer/src/lib/mathHelpers/vectors'
|
|
13
|
+
import { createAnim, UserAnimation } from '../renderer/src/lib/animation/protocols'
|
|
14
|
+
import { easeInOutQuad } from '../renderer/src/lib/animation/interpolations'
|
|
15
|
+
import {
|
|
16
|
+
fadeIn,
|
|
17
|
+
fadeOut,
|
|
18
|
+
moveCameraAnimation3D,
|
|
19
|
+
setOpacity
|
|
20
|
+
} from '../renderer/src/lib/animation/animations'
|
|
21
|
+
import { createFastText, updateText } from '../renderer/src/lib/rendering/objects2d'
|
|
22
|
+
|
|
23
|
+
const functions: [string, (x: number) => number][] = [
|
|
24
|
+
// ──────────── Start Simple & Recognizable ────────────
|
|
25
|
+
['Constant', (_) => 0],
|
|
26
|
+
['Linear', (x) => x],
|
|
27
|
+
['Exponential', (x) => Math.exp(x / 3.3)],
|
|
28
|
+
['Cubic', (x) => Math.pow(x / 10, 3) * 10],
|
|
29
|
+
['Absolute Value', (x) => Math.abs(x)],
|
|
30
|
+
['Square Root of |x|', (x) => Math.sqrt(Math.abs(x))],
|
|
31
|
+
|
|
32
|
+
// ──────────── Introduce Waves & Periodicity ────────────
|
|
33
|
+
['Sine', (x) => Math.sin(x) * 5],
|
|
34
|
+
['Cosine', (x) => Math.cos(x) * 5],
|
|
35
|
+
['Chirp', (x) => Math.sin(0.5 * x * x) * 5],
|
|
36
|
+
['Sawtooth Wave', (x) => 2 * (x / Math.PI - Math.floor(x / Math.PI + 0.5)) * 5],
|
|
37
|
+
[
|
|
38
|
+
'Triangle Wave',
|
|
39
|
+
(x) => (2 * Math.abs(2 * (x / Math.PI - Math.floor(x / Math.PI + 0.5))) - 1) * 5
|
|
40
|
+
],
|
|
41
|
+
['Square Wave', (x) => Math.sign(Math.sin(x)) * 10],
|
|
42
|
+
|
|
43
|
+
// ──────────── Smooth Curves & Transitions ────────────
|
|
44
|
+
['Gaussian Curve', (x) => Math.exp(-0.5 * x * x) * 10],
|
|
45
|
+
['ReLU', (x) => Math.max(0, x)],
|
|
46
|
+
['Sigmoid', (x) => (1 / (1 + Math.exp(-x))) * 10],
|
|
47
|
+
['Damped Sine Wave', (x) => Math.sin(x) * Math.exp(-0.05 * x * x) * 10],
|
|
48
|
+
|
|
49
|
+
// ──────────── Chaotic/Unpredictable Climax ────────────
|
|
50
|
+
['Chaotic Oscillator', (x) => Math.sin(10 * x) * Math.sin(0.3 * x * x) * 5]
|
|
51
|
+
]
|
|
52
|
+
const vectorizeFunctions = (
|
|
53
|
+
functions: [string, (x: number) => number][],
|
|
54
|
+
threshold: number = Infinity
|
|
55
|
+
): [number, number][][] => {
|
|
56
|
+
return functions.map(([label, fn]) => {
|
|
57
|
+
const xs = linspace(-10, 10, 200)
|
|
58
|
+
return xs.map((x) => {
|
|
59
|
+
const raw = fn(x)
|
|
60
|
+
// zero out anything non‐finite or exceeding threshold
|
|
61
|
+
const y = Number.isFinite(raw) && Math.abs(raw) <= threshold ? raw : 0
|
|
62
|
+
return [x, y] as [number, number]
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const interpolate = (
|
|
68
|
+
vectorFunc1: [number, number][],
|
|
69
|
+
vectorFunc2: [number, number][],
|
|
70
|
+
progress: number
|
|
71
|
+
): [number, number][] => {
|
|
72
|
+
if (vectorFunc1.length !== vectorFunc2.length) {
|
|
73
|
+
throw new Error('Both point‑lists must have the same length for interpolation.')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// clamp progress into [0,1]
|
|
77
|
+
const t = Math.max(0, Math.min(1, progress))
|
|
78
|
+
|
|
79
|
+
return vectorFunc1.map(([x1, y1], i) => {
|
|
80
|
+
const [x2, y2] = vectorFunc2[i]
|
|
81
|
+
// interpolate x and y
|
|
82
|
+
const x = x1 + (x2 - x1) * t
|
|
83
|
+
const y = y1 + (y2 - y1) * t
|
|
84
|
+
return [x, y] as [number, number]
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const morphAnimation = (
|
|
89
|
+
line: PlotLine,
|
|
90
|
+
vecFunc1: [number, number][],
|
|
91
|
+
vecFunc2: [number, number][],
|
|
92
|
+
duration: number = 500
|
|
93
|
+
) => {
|
|
94
|
+
return createAnim(easeInOutQuad(0, 1, duration), (value) => {
|
|
95
|
+
line.setPoints(interpolate(vecFunc1, vecFunc2, value))
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
const hdriData = await loadHDRIData(HDRIs.outdoor1, 1, 1)
|
|
99
|
+
|
|
100
|
+
export const functionsAnimation = (): AnimatedScene => {
|
|
101
|
+
return new AnimatedScene(
|
|
102
|
+
1080,
|
|
103
|
+
2160,
|
|
104
|
+
SpaceSetting.ThreeDim,
|
|
105
|
+
HotReloadSetting.TraceFromStart,
|
|
106
|
+
async (scene) => {
|
|
107
|
+
// scene.registerAudio(fadeSound)
|
|
108
|
+
scene.add(
|
|
109
|
+
create2DAxis({ xmin: -10, xmax: 10, ymin: -10, ymax: 10, ticks: 10, tickSize: 0.5 })
|
|
110
|
+
)
|
|
111
|
+
addSceneLighting(scene.scene)
|
|
112
|
+
|
|
113
|
+
await addHDRI(scene, hdriData, 1)
|
|
114
|
+
addBackgroundGradient({
|
|
115
|
+
scene,
|
|
116
|
+
topColor: '#00639d',
|
|
117
|
+
bottomColor: COLORS.black,
|
|
118
|
+
backgroundOpacity: 0.5
|
|
119
|
+
})
|
|
120
|
+
scene.camera.position.set(0, 0, 10)
|
|
121
|
+
const informationTextNode = await createFastText('Some of the functions are scaled.', 0.6)
|
|
122
|
+
informationTextNode.position.y = 13.5
|
|
123
|
+
setOpacity(informationTextNode, 0.4)
|
|
124
|
+
scene.add(informationTextNode)
|
|
125
|
+
const textNode = await createFastText('', 1.5)
|
|
126
|
+
textNode.position.y = 12
|
|
127
|
+
scene.add(textNode)
|
|
128
|
+
|
|
129
|
+
const plotLine = new PlotLine(scene.scene, 0xffffff)
|
|
130
|
+
|
|
131
|
+
const vecFuncs = vectorizeFunctions(functions)
|
|
132
|
+
|
|
133
|
+
scene.camera.position.set(9.82015, 4.347805, 29.81918)
|
|
134
|
+
scene.camera.quaternion.set(-0.07633829, 0.3762715, 0.03112576, 0.9228345)
|
|
135
|
+
scene.camera.zoom = 0.8
|
|
136
|
+
|
|
137
|
+
const moveAnimation = moveCameraAnimation3D(
|
|
138
|
+
scene.camera,
|
|
139
|
+
scene.camera.position.clone(),
|
|
140
|
+
new THREE.Vector3(-9.625222, 4.32878, 29.57185),
|
|
141
|
+
2500
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
scene.onEachTick(() => {
|
|
145
|
+
scene.camera.lookAt(0, 0, -2)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
scene.addSequentialBackgroundAnims(
|
|
149
|
+
...Array(20)
|
|
150
|
+
.fill(0)
|
|
151
|
+
.flatMap(() => [moveAnimation, moveAnimation.copy().reverse()])
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
for (let i = 0; i < vecFuncs.length - 1; i++) {
|
|
155
|
+
// scene.playAudio(fadeSound, 0.03)
|
|
156
|
+
|
|
157
|
+
scene.addSequentialBackgroundAnims(
|
|
158
|
+
morphAnimation(plotLine, vecFuncs[i], vecFuncs[i + 1], 300)
|
|
159
|
+
)
|
|
160
|
+
scene.addAnim(fadeOut(textNode, 150))
|
|
161
|
+
scene.do(async () => {
|
|
162
|
+
await updateText(textNode, functions[i + 1][0])
|
|
163
|
+
})
|
|
164
|
+
scene.addAnim(fadeIn(textNode, 150))
|
|
165
|
+
scene.addWait(800)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
scene.addWait(1600)
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
class PlotLine {
|
|
174
|
+
private meshLine: MeshLine
|
|
175
|
+
private mesh: THREE.Mesh
|
|
176
|
+
|
|
177
|
+
constructor(scene: THREE.Scene, color: THREE.ColorRepresentation) {
|
|
178
|
+
// create MeshLine + material + mesh
|
|
179
|
+
this.meshLine = new MeshLine()
|
|
180
|
+
const material = new MeshLineMaterial({
|
|
181
|
+
lineWidth: 0.1,
|
|
182
|
+
color,
|
|
183
|
+
transparent: false
|
|
184
|
+
})
|
|
185
|
+
this.mesh = new THREE.Mesh(this.meshLine.geometry, material)
|
|
186
|
+
scene.add(this.mesh)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
setPoints(points: [number, number][]) {
|
|
190
|
+
// flatten into [x0, y0, z0, x1, y1, z1, …]
|
|
191
|
+
const flatPoints: number[] = []
|
|
192
|
+
for (const [x, y] of points) {
|
|
193
|
+
flatPoints.push(x, y, 0)
|
|
194
|
+
}
|
|
195
|
+
// false = don't close the loop
|
|
196
|
+
this.meshLine.setPoints(flatPoints, false)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function create2DAxis({
|
|
201
|
+
xmin = -10,
|
|
202
|
+
xmax = 10,
|
|
203
|
+
ymin = -10,
|
|
204
|
+
ymax = 10,
|
|
205
|
+
ticks = 10,
|
|
206
|
+
tickSize = 0.2
|
|
207
|
+
} = {}) {
|
|
208
|
+
const group = new THREE.Group()
|
|
209
|
+
const mat = new THREE.LineBasicMaterial({ color: 0xffffff })
|
|
210
|
+
|
|
211
|
+
// main X axis
|
|
212
|
+
let geo = new THREE.BufferGeometry().setFromPoints([
|
|
213
|
+
new THREE.Vector3(xmin, 0, 0),
|
|
214
|
+
new THREE.Vector3(xmax, 0, 0)
|
|
215
|
+
])
|
|
216
|
+
group.add(new THREE.Line(geo, mat))
|
|
217
|
+
|
|
218
|
+
// main Y axis
|
|
219
|
+
geo = new THREE.BufferGeometry().setFromPoints([
|
|
220
|
+
new THREE.Vector3(0, ymin, 0),
|
|
221
|
+
new THREE.Vector3(0, ymax, 0)
|
|
222
|
+
])
|
|
223
|
+
group.add(new THREE.Line(geo, mat))
|
|
224
|
+
|
|
225
|
+
// tick marks
|
|
226
|
+
const dx = (xmax - xmin) / ticks
|
|
227
|
+
for (let i = 0; i <= ticks; i++) {
|
|
228
|
+
const x = xmin + i * dx
|
|
229
|
+
const tgeo = new THREE.BufferGeometry().setFromPoints([
|
|
230
|
+
new THREE.Vector3(x, -tickSize / 2, 0),
|
|
231
|
+
new THREE.Vector3(x, tickSize / 2, 0)
|
|
232
|
+
])
|
|
233
|
+
group.add(new THREE.Line(tgeo, mat))
|
|
234
|
+
}
|
|
235
|
+
const dy = (ymax - ymin) / ticks
|
|
236
|
+
for (let j = 0; j <= ticks; j++) {
|
|
237
|
+
const y = ymin + j * dy
|
|
238
|
+
const tgeo = new THREE.BufferGeometry().setFromPoints([
|
|
239
|
+
new THREE.Vector3(-tickSize / 2, y, 0),
|
|
240
|
+
new THREE.Vector3(tickSize / 2, y, 0)
|
|
241
|
+
])
|
|
242
|
+
group.add(new THREE.Line(tgeo, mat))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return group
|
|
246
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
|
4
|
+
import icon from '../../resources/icon.png?asset'
|
|
5
|
+
import { renderVideo } from './rendering'
|
|
6
|
+
import { deleteRenderedContent } from './storage'
|
|
7
|
+
|
|
8
|
+
let mainWindow: BrowserWindow
|
|
9
|
+
|
|
10
|
+
function createWindow(): void {
|
|
11
|
+
// Create the browser window.
|
|
12
|
+
mainWindow = new BrowserWindow({
|
|
13
|
+
width: 1000,
|
|
14
|
+
height: 1300,
|
|
15
|
+
show: false,
|
|
16
|
+
autoHideMenuBar: true,
|
|
17
|
+
...(process.platform === 'linux' ? { icon } : {}),
|
|
18
|
+
webPreferences: {
|
|
19
|
+
nodeIntegration: true,
|
|
20
|
+
contextIsolation: false,
|
|
21
|
+
nodeIntegrationInWorker: true,
|
|
22
|
+
webSecurity: true,
|
|
23
|
+
allowRunningInsecureContent: false,
|
|
24
|
+
preload: join(__dirname, '../preload/index.js'),
|
|
25
|
+
sandbox: false
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
mainWindow.on('ready-to-show', () => {
|
|
30
|
+
mainWindow.show()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
mainWindow.webContents.setWindowOpenHandler((details) => {
|
|
34
|
+
shell.openExternal(details.url)
|
|
35
|
+
return { action: 'deny' }
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// HMR for renderer base on electron-vite cli.
|
|
39
|
+
// Load the remote URL for development or the local html file for production.
|
|
40
|
+
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
|
41
|
+
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
|
42
|
+
} else {
|
|
43
|
+
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// This method will be called when Electron has finished
|
|
48
|
+
// initialization and is ready to create browser windows.
|
|
49
|
+
// Some APIs can only be used after this event occurs.
|
|
50
|
+
app.whenReady().then(() => {
|
|
51
|
+
// Set app user model id for windows
|
|
52
|
+
electronApp.setAppUserModelId('com.electron')
|
|
53
|
+
|
|
54
|
+
// Default open or close DevTools by F12 in development
|
|
55
|
+
// and ignore CommandOrControl + R in production.
|
|
56
|
+
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
|
57
|
+
app.on('browser-window-created', (_, window) => {
|
|
58
|
+
optimizer.watchWindowShortcuts(window)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// IPC test
|
|
62
|
+
ipcMain.on('ping', () => console.log('pong'))
|
|
63
|
+
|
|
64
|
+
// Listen for resize requests from the renderer
|
|
65
|
+
ipcMain.on('resize-window', (event, { width, height }) => {
|
|
66
|
+
if (mainWindow) {
|
|
67
|
+
mainWindow.setSize(width, height)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
createWindow()
|
|
72
|
+
|
|
73
|
+
app.on('activate', function () {
|
|
74
|
+
// On macOS it's common to re-create a window in the app when the
|
|
75
|
+
// dock icon is clicked and there are no other windows open.
|
|
76
|
+
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
ipcMain.handle('start-video-render', async (event, options) => {
|
|
81
|
+
try {
|
|
82
|
+
const outputFile = await renderVideo(options)
|
|
83
|
+
return { success: true, outputFile }
|
|
84
|
+
} catch (error: any) {
|
|
85
|
+
return { success: false, error: error.message }
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
deleteRenderedContent()
|
|
90
|
+
|
|
91
|
+
// Quit when all windows are closed, except on macOS. There, it's common
|
|
92
|
+
// for applications and their menu bar to stay active until the user quits
|
|
93
|
+
// explicitly with Cmd + Q.
|
|
94
|
+
app.on('window-all-closed', () => {
|
|
95
|
+
if (process.platform !== 'darwin') {
|
|
96
|
+
app.quit()
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// In this file you can include the rest of your app's specific main process
|
|
101
|
+
// code. You can also put them in separate files and require them here.
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { spawnSync } from 'child_process'
|
|
4
|
+
|
|
5
|
+
export interface AudioInScene {
|
|
6
|
+
audioPath: string
|
|
7
|
+
volume: number
|
|
8
|
+
atFrame: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RenderOptions {
|
|
12
|
+
fps: number
|
|
13
|
+
width: number
|
|
14
|
+
height: number
|
|
15
|
+
renderingAudioGather: AudioInScene[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const generateID = (numCharacters: number = 10) =>
|
|
19
|
+
Math.random().toString(numCharacters).substr(2, 9)
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Renders a video from image frames found in the latest "render" directory.
|
|
23
|
+
* @param options - Configuration options for rendering.
|
|
24
|
+
* @param options.fps - Frames per second to use (default is 30).
|
|
25
|
+
* @returns A promise that resolves to the output file path.
|
|
26
|
+
*/
|
|
27
|
+
export function renderVideo(options: RenderOptions): Promise<string> {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
try {
|
|
30
|
+
console.log(`Converting frames to video at ${options.fps} fps`)
|
|
31
|
+
|
|
32
|
+
// console.log('THE AUDIO DATA IS', options.renderingAudioGather)
|
|
33
|
+
|
|
34
|
+
// Define directories
|
|
35
|
+
const rootDir = './image_renders'
|
|
36
|
+
const audioRendersDir = './audio_renders'
|
|
37
|
+
const outputDir = './rendered_videos'
|
|
38
|
+
|
|
39
|
+
// Ensure output directory exists.
|
|
40
|
+
if (!fs.existsSync(outputDir)) {
|
|
41
|
+
fs.mkdirSync(outputDir, { recursive: true })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Ensure output directory exists.
|
|
45
|
+
if (!fs.existsSync(audioRendersDir)) {
|
|
46
|
+
fs.mkdirSync(audioRendersDir, { recursive: true })
|
|
47
|
+
}
|
|
48
|
+
const audioID = generateID(10)
|
|
49
|
+
const includeAudio = options.renderingAudioGather.length > 0
|
|
50
|
+
if (includeAudio) {
|
|
51
|
+
const audioCommand = buildAudioMixCommand(options, audioRendersDir, audioID)
|
|
52
|
+
|
|
53
|
+
console.log(`Building audio...`)
|
|
54
|
+
const audioResult = spawnSync(audioCommand, { stdio: 'inherit', shell: true })
|
|
55
|
+
if (audioResult.status !== 0) {
|
|
56
|
+
return reject(new Error('FFmpeg command failed'))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Find the latest render directory.
|
|
61
|
+
const latestDir = findLatestDir(rootDir)
|
|
62
|
+
const dirName = path.basename(latestDir)
|
|
63
|
+
console.log(`Processing directory: ${dirName}`)
|
|
64
|
+
|
|
65
|
+
// Define the frame pattern and output file path.
|
|
66
|
+
const framePattern = path.join(latestDir, 'frame_%05d.jpeg')
|
|
67
|
+
const outputFile = path.join(outputDir, `${dirName}.mp4`)
|
|
68
|
+
|
|
69
|
+
// Start building the ffmpeg arguments array.
|
|
70
|
+
const ffmpegArgs = [
|
|
71
|
+
'-y', // Overwrite output if it exists.
|
|
72
|
+
'-framerate',
|
|
73
|
+
options.fps.toString(), // Frame rate for the video.
|
|
74
|
+
'-i',
|
|
75
|
+
framePattern // Input frames pattern.
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
// Conditionally add the audio input if needed.
|
|
79
|
+
if (includeAudio) {
|
|
80
|
+
ffmpegArgs.push(
|
|
81
|
+
'-i',
|
|
82
|
+
`${audioRendersDir}/${audioID}.mp3` // Input audio file.
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Common encoding parameters for video.
|
|
87
|
+
ffmpegArgs.push(
|
|
88
|
+
'-c:v',
|
|
89
|
+
'libx264', // Video codec.
|
|
90
|
+
'-pix_fmt',
|
|
91
|
+
'yuv420p' // Pixel format.
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
// Conditionally add audio codec settings only if audio is included.
|
|
95
|
+
if (includeAudio) {
|
|
96
|
+
ffmpegArgs.push(
|
|
97
|
+
'-c:a',
|
|
98
|
+
'aac', // Audio codec.
|
|
99
|
+
'-af',
|
|
100
|
+
'apad', // <-- pad audio with silence
|
|
101
|
+
'-shortest' // <-- cut output to shortest input (i.e. video)
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Additional encoding options for both scenarios.
|
|
106
|
+
ffmpegArgs.push(
|
|
107
|
+
'-preset',
|
|
108
|
+
'fast', // Preset for encoding speed/quality.
|
|
109
|
+
'-crf',
|
|
110
|
+
'23', // Quality/size balance.
|
|
111
|
+
outputFile // Output file.
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
console.log('Executing FFmpeg command:')
|
|
115
|
+
console.log(`ffmpeg ${ffmpegArgs.join(' ')}`)
|
|
116
|
+
|
|
117
|
+
// Execute FFmpeg synchronously.
|
|
118
|
+
const result = spawnSync('ffmpeg', ffmpegArgs, { stdio: 'inherit' })
|
|
119
|
+
if (result.status !== 0) {
|
|
120
|
+
return reject(new Error('FFmpeg command failed'))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log(`Video created successfully: ${outputFile}`)
|
|
124
|
+
|
|
125
|
+
// Delete the render directory with all its images.
|
|
126
|
+
fs.rmSync(latestDir, { recursive: true, force: true })
|
|
127
|
+
fs.readdirSync(audioRendersDir).forEach((item) => {
|
|
128
|
+
const itemPath = path.join(audioRendersDir, item)
|
|
129
|
+
fs.rmSync(itemPath, { recursive: true, force: true })
|
|
130
|
+
})
|
|
131
|
+
console.log(`Deleted render folder: ${latestDir}`)
|
|
132
|
+
|
|
133
|
+
resolve(outputFile)
|
|
134
|
+
} catch (err) {
|
|
135
|
+
reject(err)
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
/*
|
|
140
|
+
function generateFFmpegAudioCommand(
|
|
141
|
+
options: RenderOptions,
|
|
142
|
+
outputFolder: string,
|
|
143
|
+
id: string
|
|
144
|
+
): string {
|
|
145
|
+
const inputs: string[] = []
|
|
146
|
+
const filterChains: string[] = []
|
|
147
|
+
const amixInputs: string[] = []
|
|
148
|
+
|
|
149
|
+
options.renderingAudioGather.forEach((audio, index) => {
|
|
150
|
+
const startTime = audio.atFrame / options.fps
|
|
151
|
+
inputs.push(`-i ./src/renderer${audio.audioPath}`)
|
|
152
|
+
filterChains.push(`[${index}:a]asetpts=PTS+${startTime}/TB,volume=${audio.volume}[a${index}]`)
|
|
153
|
+
amixInputs.push(`[a${index}]`)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const amixFilter = `${amixInputs.join('')}amix=inputs=${amixInputs.length}:duration=longest[aout]`
|
|
157
|
+
const filterComplex = [...filterChains, amixFilter].join('; ')
|
|
158
|
+
|
|
159
|
+
return `ffmpeg ${inputs.join(' ')} -filter_complex "${filterComplex}" -map "[aout]" -c:a libmp3lame -q:a 2 ${outputFolder}/${id}.mp3`
|
|
160
|
+
}*/
|
|
161
|
+
|
|
162
|
+
function buildAudioMixCommand(renderOptions: RenderOptions, outputFolder: string, id: string) {
|
|
163
|
+
const { fps, renderingAudioGather } = renderOptions
|
|
164
|
+
let inputs: string[] = []
|
|
165
|
+
let filters: string[] = []
|
|
166
|
+
let inputIndexes: string[] = []
|
|
167
|
+
|
|
168
|
+
renderingAudioGather.forEach((audio, index) => {
|
|
169
|
+
inputs.push(`-i ./src/renderer${audio.audioPath}`)
|
|
170
|
+
const delayMs = Math.floor((audio.atFrame / fps) * 1000)
|
|
171
|
+
// adelay syntax: "adelay=delay_in_ms|delay_in_ms"
|
|
172
|
+
filters.push(`[${index}:a]adelay=${delayMs}|${delayMs},volume=${audio.volume}[a${index}]`)
|
|
173
|
+
inputIndexes.push(`[a${index}]`)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const filterComplex =
|
|
177
|
+
filters.join('; ') +
|
|
178
|
+
'; ' +
|
|
179
|
+
inputIndexes.join('') +
|
|
180
|
+
`amix=inputs=${renderingAudioGather.length}:duration=longest:normalize=0[out]`
|
|
181
|
+
|
|
182
|
+
// Adding -hide_banner and -loglevel error to reduce terminal output.
|
|
183
|
+
const command = `ffmpeg -hide_banner -loglevel error ${inputs.join(' ')} -filter_complex "${filterComplex}" -map "[out]" ${outputFolder}/${id}.mp3`
|
|
184
|
+
return command
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Finds the most recent directory in the given path that starts with "render".
|
|
189
|
+
* @param dirPath - The root directory to search.
|
|
190
|
+
* @returns The full path of the latest render directory.
|
|
191
|
+
* @throws If the directory does not exist or no render directories are found.
|
|
192
|
+
*/
|
|
193
|
+
function findLatestDir(dirPath: string): string {
|
|
194
|
+
if (!fs.existsSync(dirPath)) {
|
|
195
|
+
throw new Error(`Directory not found: ${dirPath}`)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const entries = fs.readdirSync(dirPath)
|
|
199
|
+
let newestDir: string | null = null
|
|
200
|
+
let newestTime = 0 // milliseconds
|
|
201
|
+
|
|
202
|
+
entries.forEach((entry) => {
|
|
203
|
+
const fullPath = path.join(dirPath, entry)
|
|
204
|
+
const stat = fs.statSync(fullPath)
|
|
205
|
+
if (stat.isDirectory() && entry.startsWith('render')) {
|
|
206
|
+
// Prefer creation time if available; fallback to modified time.
|
|
207
|
+
const time = stat.birthtimeMs || stat.mtimeMs
|
|
208
|
+
if (time > newestTime) {
|
|
209
|
+
newestTime = time
|
|
210
|
+
newestDir = fullPath
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
if (!newestDir) {
|
|
216
|
+
throw new Error('No render directories found')
|
|
217
|
+
}
|
|
218
|
+
return newestDir
|
|
219
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import path from 'path'
|
|
2
|
+
import fs from 'fs/promises'
|
|
3
|
+
import fsSync from 'fs'
|
|
4
|
+
|
|
5
|
+
export async function deleteRenderedContent() {
|
|
6
|
+
const folderPath = './image_renders'
|
|
7
|
+
const audioRendersDir = './audio_renders'
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
// Read all entries in the folder.
|
|
11
|
+
const entries = await fs.readdir(folderPath, { withFileTypes: true })
|
|
12
|
+
|
|
13
|
+
// Filter for directories starting with "render_".
|
|
14
|
+
const renderDirs = entries.filter(
|
|
15
|
+
(entry) => entry.isDirectory() && entry.name.startsWith('render_')
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// Loop over the filtered directories and remove each.
|
|
19
|
+
await Promise.all(
|
|
20
|
+
renderDirs.map(async (entry) => {
|
|
21
|
+
const fullPath = path.join(folderPath, entry.name)
|
|
22
|
+
await fs.rm(fullPath, { recursive: true, force: true })
|
|
23
|
+
})
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
fsSync.readdirSync(audioRendersDir).forEach((item) => {
|
|
27
|
+
const itemPath = path.join(audioRendersDir, item)
|
|
28
|
+
fsSync.rmSync(itemPath, { recursive: true, force: true })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
console.log('All render cache have been deleted.')
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error(`Error while deleting render folders: ${(error as any).message}`)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { contextBridge, ipcRenderer } from 'electron'
|
|
2
|
+
import { electronAPI } from '@electron-toolkit/preload'
|
|
3
|
+
import { RenderOptions } from '../main/rendering'
|
|
4
|
+
|
|
5
|
+
const customAPI = {
|
|
6
|
+
startVideoRender: (options: RenderOptions) => ipcRenderer.invoke('start-video-render', options)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Custom APIs for renderer
|
|
10
|
+
const api = {
|
|
11
|
+
...customAPI
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Use `contextBridge` APIs to expose Electron APIs to
|
|
15
|
+
// renderer only if context isolation is enabled, otherwise
|
|
16
|
+
// just add to the DOM global.
|
|
17
|
+
if (process.contextIsolated) {
|
|
18
|
+
try {
|
|
19
|
+
contextBridge.exposeInMainWorld('electron', electronAPI)
|
|
20
|
+
contextBridge.exposeInMainWorld('api', api)
|
|
21
|
+
} catch (error) {
|
|
22
|
+
console.error(error)
|
|
23
|
+
}
|
|
24
|
+
} else {
|
|
25
|
+
// @ts-ignore (define in dts)
|
|
26
|
+
window.electron = electronAPI
|
|
27
|
+
// @ts-ignore (define in dts)
|
|
28
|
+
window.api = api
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/*
|
|
32
|
+
contextBridge.exposeInMainWorld('api', {
|
|
33
|
+
// Expose a method to start the video render
|
|
34
|
+
startVideoRender: (options: RenderOptions) => ipcRenderer.invoke('start-video-render', options)
|
|
35
|
+
})
|
|
36
|
+
*/
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<title>Electron</title>
|
|
6
|
+
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
|
7
|
+
<!-- <meta
|
|
8
|
+
http-equiv="Content-Security-Policy"
|
|
9
|
+
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
|
|
10
|
+
/>-->
|
|
11
|
+
</head>
|
|
12
|
+
|
|
13
|
+
<body>
|
|
14
|
+
<div id="app"></div>
|
|
15
|
+
<script type="module" src="/src/main.ts"></script>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|