How to Run a 355B Parameter MoE Model on Ancient 8-Socket CPU-Only Hardware: A Complete Chronological Optimization Guide for GLM-4.7 on Lenovo System x3950 X6
Hardware Overview
Inference Engine Compilation
First Attempts with BF16 Quantization
Early Q8_0 Tests (Pre-BIOS Optimization)
BIOS Optimization and BLAS Experiments
Batch Size Sweep (with optimal 64 threads)
Testing Experimental Flags (Graph Reuse & RoPE)
Final Linux Tweaks (Including THP, Cache Drop, and BLAS Threading Control)
Final Optimized Setup
Some Real-World Benchmarks (some more Tweaks)
Lockstep vs Independent Memory Mode on the x3950 X6
Power Draw Analysis
Test Prompts and Model Intelligence
Zhipu AI’s GLM-4.7 is an open-weight Mixture-of-Experts (MoE) model with 355 billion total parameters but only ~32 billion active per token. Its architecture, combined with GGUF quantization and specialized llama.cpp forks, makes CPU-only inference surprisingly viable—even on hardware from 2015.
This guide follows the exact chronological order of the optimization process on a 2015 Lenovo System x3950 X6 (8 sockets, 1 TB DDR4 RAM, no GPUs). I started with unusable speeds and ended with ~5 tokens/second generation (up to 7 token/s at short outputs) in Q8_0.
(Example Outputs from bottom for „Intelligence demonstration“ in Q8: Output 1: https://postl.ai/output/game.html and Output2: https://postl.ai/output/videoeditor.html)

Hardware Overview
In early 2025, I snagged this impressive Lenovo System x3950 X6 server, complete with 1TB of DDR4 RAM, for around 1000€ on eBay. What started as a budget-friendly dive into vintage enterprise hardware quickly turned into an exciting experiment in pushing the limits of CPU-only inference for massive AI models like GLM-4.7.
- Server: Lenovo System x3950 X6
- CPUs: 8 × Intel Xeon E7-8880 v3 (Haswell-EX, 18 cores each → 144 physical cores, 288 threads with HT)
- RAM: 1 TB DDR4 (64x 16GB Micron 2133 MT/s)
- Platform limit: Maximum memory speed 1866 MT/s in fully populated 8-socket configuration (documented Lenovo constraint)
orakel@orakel:~$ fastfetch
.',;::::;,'. orakel@orakel
.';:cccccccccccc:;,. -------------
.;cccccccccccccccccccccc;. OS: Fedora Linux 43 (Server Edition) x86_64
.:cccccccccccccccccccccccccc:. Host: Lenovo System x3950 X6 -[6241AC2]- (09)
.;ccccccccccccc;.:dddl:.;ccccccc;. Kernel: Linux 6.17.12-300.fc43.x86_64
.:ccccccccccccc;OWMKOOXMWd;ccccccc:. Uptime: 19 hours, 36 mins
.:ccccccccccccc;KMMc;cc;xMMc;ccccccc:. Packages: 1225 (rpm)
,cccccccccccccc;MMM.;cc;;WW:;cccccccc, Shell: bash 5.3.0
:cccccccccccccc;MMM.;cccccccccccccccc: Terminal: /dev/pts/1
:ccccccc;oxOOOo;MMM000k.;cccccccccccc: CPU: 8 x Intel(R) Xeon(R) E7-8880 v3 (144) @ 3.10 GHz
cccccc;0MMKxdd:;MMMkddc.;cccccccccccc; GPU 1: Matrox Electronics Systems Ltd. G200eR2
ccccc;XMO';cccc;MMM.;cccccccccccccccc' GPU 2: Matrox Electronics Systems Ltd. G200eR2
ccccc;MMo;ccccc;MMW.;ccccccccccccccc; Memory: 361.95 GiB / 1007.72 GiB (36%)
ccccc;0MNc.ccc.xMMd;ccccccccccccccc; Swap: 0 B / 8.00 GiB (0%)
cccccc;dNMWXXXWM0:;cccccccccccccc:, Disk (/): 36.49 GiB / 555.26 GiB (7%) - xfs
cccccccc;.:odl:.;cccccccccccccc:,. Disk (/data): 1.53 TiB / 1.82 TiB (84%) - xfs
ccccccccccccccccccccccccccccc:'. Local IP (eno27): 10.0.0.15/24
:ccccccccccccccccccccccc:;,.. Locale: de_AT.UTF-8
':cccccccccccccccc::;,.
orakel@orakel:~$
NUMA topology (verified during testing):
orakel@orakel:/data/models/GLM-4.7-Q8_0$ numactl --hardware
available: 8 nodes (0-7)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161
node 0 size: 128769 MB
node 0 free: 926 MB
node 1 cpus: 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179
node 1 size: 129012 MB
node 1 free: 356 MB
node 2 cpus: 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197
node 2 size: 129012 MB
node 2 free: 461 MB
node 3 cpus: 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215
node 3 size: 129012 MB
node 3 free: 454 MB
node 4 cpus: 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233
node 4 size: 128958 MB
node 4 free: 199 MB
node 5 cpus: 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251
node 5 size: 129012 MB
node 5 free: 314 MB
node 6 cpus: 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269
node 6 size: 129012 MB
node 6 free: 544 MB
node 7 cpus: 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287
node 7 size: 128997 MB
node 7 free: 439 MB
node distances:
node 0 1 2 3 4 5 6 7
0: 10 11 12 11 11 12 12 12
1: 11 10 11 12 12 11 12 12
2: 12 11 10 11 12 12 11 12
3: 11 12 11 10 12 12 12 11
4: 11 12 12 12 10 11 11 12
5: 12 11 12 12 11 10 12 11
6: 12 12 11 12 11 12 10 11
7: 12 12 12 11 12 11 11 10
orakel@orakel:/data/models/GLM-4.7-Q8_0$
Inference Engine Compilation (Throughout Testing)
We used a llama.cpp fork (ik_llama.cpp) with GLM MoE support. Compiled without BLAS initially, but tested with/without during experiments.
Compilation command (no BLAS for most tests):
cd ~/ik_llama.cpp
rm -rf build
cmake -B build -DGGML_BLAS=OFF
cmake --build build -j"$(nproc)"
First Attempts with BF16 Quantization
In the initial phase of testing GLM-4.7 on the Lenovo System x3950 X6, we started with the model’s native BF16 quantization format. This format preserves high precision but comes at the cost of massive memory usage—clocking in at around 657 GiB for the full model. Given the hardware’s 1 TB RAM limit, this was feasible but left little headroom for overhead. Our goal was to establish a baseline for inference speed without any advanced optimizations.
We began with a basic thread sweep using the ik_llama.cpp server, running without any NUMA-specific flags. The command looked like this:
Basic thread sweep (no NUMA flags):
Outputs varied slightly but hovered around 0.4 tokens per second (t/s) for generation across thread counts from 48 to 144.
=== threads 48 ===
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | CPU | 48 | 64 | 64 | tg128 | 0.41 ± 0.00 |
=== threads 64 ===
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | CPU | 64 | 64 | 64 | tg128 | 0.42 ± 0.00 |
=== threads 72 ===
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | CPU | 72 | 64 | 64 | tg128 | 0.41 ± 0.00 |
=== threads 96 ===
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | CPU | 96 | 64 | 64 | tg128 | 0.41 ± 0.00 |
=== threads 128 ===
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | CPU | 128 | 64 | 64 | tg128 | 0.41 ± 0.00 |
=== threads 144 ===
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | CPU | 144 | 64 | 64 | tg128 | 0.40 ± 0.00 |
Prefill test:
orakel@orakel:~$ ~/ik_llama.cpp/build/bin/llama-bench -m /data/models/GLM-4.7-BF16/GLM-4.7-BF16-00001-of-00015.gguf -t 96
| model | size | params | backend | threads | test | t/s |
| ------------------------------ | ---------: | ---------: | ---------- | ------: | ------------: | ---------------: |
| ======================================= HAVE_FANCY_SIMD is NOT defined
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | CPU | 96 | pp512 | 16.63 ± 0.36 |
Next, we introduced NUMA distribution and the fast Mixture-of-Experts (fmoe) flag:
With NUMA distribute and fmoe:
=== threads 56 numa distribute fmoe 1 ===
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | CPU | 56 | 64 | 64 | tg128 | 0.42 ± 0.00 |
=== threads 64 numa distribute fmoe 1 ===
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | CPU | 64 | 64 | 64 | tg128 | 0.42 ± 0.00 |
=== threads 72 numa distribute fmoe 1 ===
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | CPU | 72 | 64 | 64 | tg128 | 0.42 ± 0.00 |
=== threads 80 numa distribute fmoe 1 ===
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | CPU | 80 | 64 | 64 | tg128 | 0.42 ± 0.00 |
orakel@orakel:~$
NUMA policy comparison:
=== numa distribute (threads 64, fmoe 1) ===
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | CPU | 64 | 64 | 64 | tg128 | 0.43 ± 0.00 |
=== numa isolate (threads 64, fmoe 1) ===
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | CPU | 64 | 64 | 64 | tg128 | 0.08 ± 0.00 |
=== numa numactl (threads 64, fmoe 1) ===
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | CPU | 64 | 64 | 64 | tg128 | 0.42 ± 0.00 |
orakel@orakel:~$
This bumped speeds marginally to ~0.42 t/s, but no significant gains. We compared NUMA policies like distribute vs. isolate:
- Distribute: numactl –interleave=all → ~0.42 t/s
- Isolate: numactl –cpunodebind=0 → Dropped to ~0.08 t/s due to memory contention
Conclusion from Phase 1: BF16 was catastrophically slow (~0.4 t/s), with little variation from threads or NUMA. Switched to Q8_0 for better performance.
Early Q8_0 Tests (Pre-BIOS Optimization)
Shifting to Q8_0 quantization reduced the model size to ~349 GiB, making it more manageable on our 1 TB RAM setup. This format strikes a balance between precision and performance, ideal for CPU inference.
Our initial untuned run used:Model size ~349 GiB.
Command – Initial untuned run:
orakel@orakel:~$ MODEL="/data/models/GLM-4.7-Q8_0/GLM-4.7-Q8_0-00001-of-00008.gguf"
~/ik_llama.cpp/build/bin/llama-bench \
-m "$MODEL" \
-t 64 \
--numa distribute \
-fmoe 1 \
-pg 16,128 \
-b 64 -ub 64 \
-r 1 -w 0 \
| grep -E 'tg128|pp16'
Full output:
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 64 | tg128 | 0.75 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 64 | pp16+tg128 | 0.81 ± 0.00 |
orakel@orakel:~$
Generation speeds improved to 0.75–0.81 t/s, a noticeable jump from BF16. We conducted an early thread sweep, testing from 32 to 288 threads:
- Command – Early thread sweep:
orakel@orakel:~$ MODEL="/data/models/GLM-4.7-Q8_0/GLM-4.7-Q8_0-00001-of-00008.gguf"
for th in 32 48 56 64 72 80 96 112 128 144; do
echo "=== threads $th (GEN only tg128) ==="
~/ik_llama.cpp/build/bin/llama-bench \
-m "$MODEL" \
-t "$th" \
--numa distribute \
-fmoe 1 \
-pg 16,128 \
-b 64 -ub 64 \
-r 1 \
-w 0 \
| grep -E '^\| .* tg128 ' || true
done
Full output:
=== threads 32 (GEN only tg128) ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 32 | 64 | 64 | tg128 | 0.74 ± 0.00 |
=== threads 48 (GEN only tg128) ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 48 | 64 | 64 | tg128 | 0.75 ± 0.00 |
=== threads 56 (GEN only tg128) ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 56 | 64 | 64 | tg128 | 0.75 ± 0.00 |
=== threads 64 (GEN only tg128) ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 64 | 64 | tg128 | 0.75 ± 0.00 |
=== threads 72 (GEN only tg128) ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 72 | 64 | 64 | tg128 | 0.73 ± 0.00 |
NUMA flags were applied similarly to Phase 1, but gains were minimal (~0.05 t/s at best). The system showed signs of thread oversubscription and potential BIOS-level inefficiencies, such as suboptimal power management. While Q8_0 was a step forward, speeds remained below 1 t/s, prompting us to dive into BIOS optimizations.
Conclusion from Phase 2: Q8_0 improved to ~0.75 t/s, but still low. Proceeded to BIOS tweaks.
BIOS Optimization and BLAS Experiments
Adjusted BIOS to Maximum Performance profile, disabled Hyper-Threading and some other changes (Disable Patrol Scrub,



Note: Change Operating Mode: Maximum Performance
During this phase, we also experimented with BLAS. Compiling with -DGGML_BLAS=ON sometimes showed in outputs as „BLAS“, but performance was often worse due to internal threading oversubscription. We found native CPU kernels (BLAS=OFF) better, but when using BLAS, forced single-threading via env vars.
BLAS test compilation (for experiments):
cd ~/ik_llama.cpp
rm -rf build
cmake -B build -DGGML_BLAS=ON
cmake --build build -j"$(nproc)"
In benchmarks, „BLAS“ label appeared, but we controlled threading:
export OMP_NUM_THREADS=1
export OPENBLAS_NUM_THREADS=1
export MKL_NUM_THREADS=1
export BLIS_NUM_THREADS=1
export VECLIB_MAXIMUM_THREADS=1
BLAS-enabled runs were ~10-20% slower in some cases (~2.5-3 t/s vs 3+ without oversubscription), so stuck with single-threaded or OFF for final.
Command – Post-BIOS thread sweep:
orakel@orakel:~$ MODEL="/data/models/GLM-4.7-Q8_0/GLM-4.7-Q8_0-00001-of-00008.gguf"
for th in 64 72 80 88 96 104 112 120 128 144; do
echo "=== threads $th ==="
~/ik_llama.cpp/build/bin/llama-bench \
-m "$MODEL" \
-t "$th" \
--numa distribute \
-fmoe 1 \
-pg 16,128 \
-b 64 -ub 64 \
-r 1 \
-w 0 \
| grep -E 'tg128|pp16'
done
Full output:
=== threads 64 ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 64 | 64 | tg128 | 3.04 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 64 | 64 | pp16+tg128 | 3.31 ± 0.00 |
=== threads 72 ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 72 | 64 | 64 | tg128 | 1.85 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 72 | 64 | 64 | pp16+tg128 | 2.00 ± 0.00 |
=== threads 80 ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 80 | 64 | 64 | tg128 | 1.85 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 80 | 64 | 64 | pp16+tg128 | 2.00 ± 0.00 |
Command – Fine-grained thread sweep:
orakel@orakel:~$ MODEL="/data/models/GLM-4.7-Q8_0/GLM-4.7-Q8_0-00001-of-00008.gguf"
for th in 48 52 56 60 62 64 66 68 70; do
echo "=== threads $th ==="
~/ik_llama.cpp/build/bin/llama-bench \
-m "$MODEL" \
-t "$th" \
--numa distribute \
-fmoe 1 \
-pg 16,128 \
-b 64 -ub 64 \
-r 1 \
-w 0 \
| grep -E 'tg128|pp16'
done
Full output:
=== threads 48 ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 48 | 64 | 64 | tg128 | 1.89 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 48 | 64 | 64 | pp16+tg128 | 2.05 ± 0.00 |
=== threads 52 ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 52 | 64 | 64 | tg128 | 1.76 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 52 | 64 | 64 | pp16+tg128 | 1.92 ± 0.00 |
=== threads 56 ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 56 | 64 | 64 | tg128 | 2.00 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 56 | 64 | 64 | pp16+tg128 | 2.17 ± 0.00 |
=== threads 60 ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 60 | 64 | 64 | tg128 | 1.89 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 60 | 64 | 64 | pp16+tg128 | 2.04 ± 0.00 |
=== threads 62 ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 62 | 64 | 64 | tg128 | 1.88 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 62 | 64 | 64 | pp16+tg128 | 2.04 ± 0.00 |
=== threads 64 ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 64 | 64 | tg128 | 3.03 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 64 | 64 | pp16+tg128 | 3.30 ± 0.00 |
=== threads 66 ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 66 | 64 | 64 | tg128 | 2.12 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 66 | 64 | 64 | pp16+tg128 | 2.30 ± 0.00 |
=== threads 68 ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 68 | 64 | 64 | tg128 | 1.88 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 68 | 64 | 64 | pp16+tg128 | 2.04 ± 0.00 |
=== threads 70 ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 70 | 64 | 64 | tg128 | 1.80 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 70 | 64 | 64 | pp16+tg128 | 1.95 ± 0.00 |
Conclusion from Phase 3: BIOS changes boosted to ~3 t/s at 64 threads. BLAS experiments showed it could be counterproductive without threading control; env vars mitigated but native often won.
Batch Size Sweep (with optimal 64 threads)
With 64 threads established as optimal, we explored batch size impacts on generation efficiency. Larger batches can amortize overhead in MoE models like GLM-4.7.
We tested batch sizes from 16 to 512 using:
Command:
orakel@orakel:~$ MODEL="/data/models/GLM-4.7-Q8_0/GLM-4.7-Q8_0-00001-of-00008.gguf"
for b in 16 32 48 64 96 128 192 256 384 512; do
echo "=== batch $b ubatch $b (threads 64) ==="
~/ik_llama.cpp/build/bin/llama-bench \
-m "$MODEL" \
-t 64 \
--numa distribute \
-fmoe 1 \
-pg 16,128 \
-b "$b" -ub "$b" \
-r 1 \
-w 0 \
| grep -E 'tg128|pp16'
done
Full output:
=== batch 16 ubatch 16 (threads 64) ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 16 | 16 | tg128 | 3.03 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 16 | 16 | pp16+tg128 | 3.30 ± 0.00 |
=== batch 32 ubatch 32 (threads 64) ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 32 | 32 | tg128 | 3.03 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 32 | 32 | pp16+tg128 | 3.30 ± 0.00 |
=== batch 48 ubatch 48 (threads 64) ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 48 | 48 | tg128 | 3.03 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 48 | 48 | pp16+tg128 | 3.31 ± 0.00 |
=== batch 64 ubatch 64 (threads 64) ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 64 | 64 | tg128 | 3.03 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 64 | 64 | pp16+tg128 | 3.30 ± 0.00 |
=== batch 96 ubatch 96 (threads 64) ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 96 | 96 | tg128 | 3.04 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 96 | 96 | pp16+tg128 | 3.32 ± 0.00 |
=== batch 128 ubatch 128 (threads 64) ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 128 | 128 | tg128 | 3.03 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 128 | 128 | pp16+tg128 | 3.31 ± 0.00 |
=== batch 192 ubatch 192 (threads 64) ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 192 | 192 | tg128 | 3.04 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 192 | 192 | pp16+tg128 | 3.31 ± 0.00 |
=== batch 256 ubatch 256 (threads 64) ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 256 | 256 | tg128 | 3.03 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 256 | 256 | pp16+tg128 | 3.30 ± 0.00 |
=== batch 384 ubatch 384 (threads 64) ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 384 | 384 | tg128 | 3.04 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 384 | 384 | pp16+tg128 | 3.31 ± 0.00 |
=== batch 512 ubatch 512 (threads 64) ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 512 | tg128 | 3.16 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | CPU | 64 | 512 | pp16+tg128 | 3.45 ± 0.00 |
Performance held steady across most sizes, with only minor gains at higher batches. This suggested the hardware’s memory bandwidth (limited to 1866 MT/s) was becoming a factor, but batch tuning provided a free 15% uplift over defaults.
Testing Experimental Flags (Graph Reuse & RoPE)
Building on prior optimizations, we experimented with advanced flags in ik_llama.cpp: graph reuse (-gr) for caching computation graphs and Rotary Positional Embeddings tweaks (-rtr).
Command:
orakel@orakel:~/ik_llama.cpp$ MODEL="/data/models/GLM-4.7-Q8_0/GLM-4.7-Q8_0-00001-of-00008.gguf"
for gr in 0 1; do
for rtr in 0 1; do
echo "=== gr=$gr rtr=$rtr ==="
~/ik_llama.cpp/build/bin/llama-bench \
-m "$MODEL" \
-t 64 \
--numa distribute \
-fmoe 1 \
-gr "$gr" \
-rtr "$rtr" \
-pg 0,128 \
-b 512 -ub 512 \
-r 1 -w 0 \
| grep -E 'tg128'
done
done
Full output:
=== gr=0 rtr=0 ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | BLAS | 64 | 512 | tg128 | 3.17 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | BLAS | 64 | 512 | pp0+tg128 | 3.18 ± 0.00 |
=== gr=0 rtr=1 ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | BLAS | 64 | 512 | 1 | tg128 | 0.79 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | BLAS | 64 | 512 | 1 | pp0+tg128 | 0.86 ± 0.00 |
=== gr=1 rtr=0 ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | BLAS | 64 | 512 | 1 | tg128 | 2.25 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | BLAS | 64 | 512 | 1 | pp0+tg128 | 2.28 ± 0.00 |
=== gr=1 rtr=1 ===
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | BLAS | 64 | 512 | 1 | 1 | tg128 | 0.76 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | BLAS | 64 | 512 | 1 | 1 | pp0+tg128 | 0.82 ± 0.00 |
Final Linux Tweaks (Including THP, Cache Drop, and BLAS Threading Control)
These tweaks cleared caches, optimized Transparent Huge Pages (THP), and ensured BLAS didn’t oversubscribe threads.
Commands:
sudo sync
echo 3 | sudo tee /proc/sys/vm/drop_caches
echo always | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/defrag
(These boosted final runs by reducing fragmentation and ensuring consistent memory access.)
Also reapplied BLAS threading env vars if BLAS was on.
Final benchmark command:
orakel@orakel:~/ik_llama.cpp$ MODEL="/data/models/GLM-4.7-Q8_0/GLM-4.7-Q8_0-00001-of-00008.gguf"
~/ik_llama.cpp/build/bin/llama-bench \
-m "$MODEL" -t 64 --numa distribute -fmoe 1 \
-pg 16,128 -b 512 -ub 512 -r 1 -w 0 \
| grep -E 'tg128|pp16'
Full output:
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | BLAS | 64 | 512 | tg128 | 4.96 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | BLAS | 64 | 512 | pp16+tg128 | 6.09 ± 0.00 |
Final Optimized Setup
Always run these before benchmarks/server:
sudo cpupower frequency-set -g performance
export OMP_NUM_THREADS=1
export OPENBLAS_NUM_THREADS=1
export MKL_NUM_THREADS=1
export BLIS_NUM_THREADS=1
export VECLIB_MAXIMUM_THREADS=1
sudo sync
echo 3 | sudo tee /proc/sys/vm/drop_caches
echo always | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/defrag
MODEL="/data/models/GLM-4.7-Q8_0/GLM-4.7-Q8_0-00001-of-00008.gguf"
~/ik_llama.cpp/build/bin/llama-bench \
-m "$MODEL" -t 64 --numa distribute -fmoe 1 \
-pg 0,128 -b 512 -ub 512 -r 1 -w 0
Peak ~4.96–6 t/s (tg/pp), viable for offline use.
This guide shows legacy hardware can handle massive MoE models with sequential tuning. BLAS requires careful threading control, and Linux tweaks like THP/cache drop provide the final boost.
Some Real-World Benchmarks
To further push the performance envelope on this ancient hardware, I disabled Intel Virtualization Technology (VT-x) in the BIOS under the Processors menu, which reduced unnecessary overhead for memory mapping and DMA checks—ideal since this setup runs no VMs. Additionally, I appended mitigations=off to the GRUB kernel parameters to bypass CPU vulnerability mitigations like Spectre and Meltdown, yielding a noticeable 10-15% uplift in sustained throughput at the cost of security (only suitable for this isolated offline benchmark machine). Note that HPET was already disabled via the nohpet kernel flag from earlier tweaks, contributing to lower interrupt latency. With these changes, the real-world benchmarks under „Some Real-World Benchmarks“ reflected clear improvements in stability and speed: for a typical RAG-like scenario with a 2048-token prompt and 256-token generation, the combined rate reached ~23.18 t/s (pp2048 at ~39.46 t/s, tg256 at ~6.30 t/s), while longer-context tasks (e.g., pp8192+tg512 at ~21.42 t/s) became noticeably more responsive for practical offline use like code analysis or extended reasoning.
#!/usr/bin/env bash
set -euo pipefail
cd ~/ik_llama.cpp/build/bin
MODEL="/data/models/GLM-4.7-Q8_0/GLM-4.7-Q8_0-00001-of-00008.gguf"
echo "=== GLM-4.7-Q8_0 Real-World Benchmark (CPU, $(nproc) Threads) ==="
echo "NUMA distribute | fmoe 1 | 3 Runs pro Test | Batch 512 (wie gewünscht)"
echo
# Hier starten die Tests: Reine PP, TG und PG-Kombis
./llama-bench -m "$MODEL" \
-t 64 \
--numa distribute \
-fmoe 1 \
-w 0 \
-r 3 \
-b 512 -ub 512 \
-p 512,2048,8192,16384 \
-n 256 \
-pg 512,128 \
-pg 2048,256 \
-pg 8192,512 \
-pg 16384,512 \
| grep -E 'pp|tg|pg|build|model|threads|test|t/s|load|eval'
=== GLM-4.7-Q8_0 Real-World Benchmark (CPU, 144 Threads) ===
NUMA distribute | fmoe 1 | 3 Runs pro Test | Batch 512 (wie gewünscht)
| model | size | params | backend | threads | n_batch | test | t/s |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | BLAS | 64 | 512 | pp512 | 42.47 ± 1.64 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | BLAS | 64 | 512 | pp2048 | 39.46 ± 0.06 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | BLAS | 64 | 512 | pp8192 | 29.99 ± 0.06 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | BLAS | 64 | 512 | pp16384 | 21.43 ± 0.02 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | BLAS | 64 | 512 | tg256 | 6.30 ± 0.00 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | BLAS | 64 | 512 | pp512+tg128 | 19.42 ± 0.01 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | BLAS | 64 | 512 | pp2048+tg256 | 23.18 ± 0.01 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | BLAS | 64 | 512 | pp8192+tg512 | 21.42 ± 0.01 |
| glm4moe 355B.A32B Q8_0 | 349.31 GiB | 352.80 B | BLAS | 64 | 512 | pp16384+tg512 | 17.92 ± 0.01 |
BF16 results are also nice:
=== GLM-4.7-BF16 Real-World Benchmark (CPU, 64 Threads) ===
NUMA distribute | fmoe 1 | 1 Run pro Test | Batch 512 | model | size | params | backend | threads | n_batch | test | t/s |
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | BLAS | 64 | 512 | pp512 | 26.05 ± 0.00 |
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | BLAS | 64 | 512 | pp2048 | 26.32 ± 0.00 |
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | BLAS | 64 | 512 | pp8192 | 21.74 ± 0.00 |
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | BLAS | 64 | 512 | pp16384 | 16.93 ± 0.00 |
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | BLAS | 64 | 512 | tg256 | 5.49 ± 0.00 |
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | BLAS | 64 | 512 | pp512+tg128 | 15.05 ± 0.00 |
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | BLAS | 64 | 512 | pp2048+tg256 | 17.53 ± 0.00 |
| glm4moe 355B.A32B BF16 | 657.28 GiB | 352.80 B | BLAS | 64 | 512 | pp8192+tg512 | 16.64 ± 0.00 |
Lockstep vs Independent Memory Mode on the x3950 X6
One of the most subtle but impactful system-level decisions we made while optimizing GLM-4.7 on the Lenovo x3950 X6 was choosing between Lockstep and Independent memory modes in BIOS.
On this platform, “memory mode” does not simply select between mirroring or not — it fundamentally changes how the integrated memory controllers (IMCs), external memory buffers, and NUMA domains cooperate. In Lockstep mode, memory channels are paired and synchronized, which simplifies timing across the memory fabric. This typically allows the platform to run at a higher effective DRAM speed and provides slightly more deterministic latency, at the cost of reduced memory channel independence.
By contrast, Independent mode treats each memory channel separately. This increases effective parallelism at the NUMA and IMC levels, which can translate to higher aggregate throughput for extremely memory-bound workloads — like inference of a 300 B+ transformer on CPU-only hardware. In our benchmarks, this resulted in higher token generation rates even though the actual configured DRAM frequency (MT/s) under Independent was lower than under Lockstep.
Here’s the direct comparison from our runs with GLM-4.7 Q8_0:
Independent:
| glm4moe 355B.A32B Q8_0 … tg128 | 5.13 ± 0.00 |
| glm4moe 355B.A32B Q8_0 … pp0+tg128 | 5.90 ± 0.00 |
Lockstep:
| glm4moe 355B.A32B Q8_0 … tg128 | 4.94 ± 0.00 |
| glm4moe 355B.A32B Q8_0 … pp0+tg128 | 5.65 ± 0.00 |
As you can see, Independent mode delivered a modest but measurable uplift in token/s (≈3–5 %), despite the fact that the memory controller reported lower configured MT/s compared to Lockstep. This underscores that effective memory parallelism and NUMA behavior can outweigh raw DRAM frequency in such workloads.
However, a notable downside of Independent mode was system stability and behavior consistency. On this 8-socket beast with 1 TB of RAM, Independent showed more variability in benchmark outcomes and occasional NUMA effects that looked like slight imbalances across nodes. Lockstep, with its channel synchronization, felt more stable and predictable, even if the peak numbers were marginally lower. For production workloads where repeatability matters more than a few percent of throughput, Lockstep may still be preferable.
Power Draw Analysis
In addition to performance metrics, understanding the power consumption of the Lenovo System x3950 X6 during model inference is crucial for assessing operational efficiency, especially on legacy hardware with multiple sockets and high core counts. This server is equipped with 8 Platinum-rated power supplies, providing redundant and efficient power delivery (typically 80 Plus Platinum certification implies at least 90% efficiency at various loads). Power readings were monitored via the internal displays from the Integrated Management Module (IMM), which provides real-time DC power usage data aggregated across the system.
We measured power draw under two primary scenarios:
- Idle State: The system at rest, with no active inference workloads but the OS and basic services running.
- Work State: During active GLM-4.7 inference using the optimized configuration (e.g., 64 threads, Q8_0 quantization, and high batch sizes as detailed in the optimization phases).
The results are as follows:
| State | Total DC Power In Use | Estimated AC Power Draw (Wall Socket) | Notes |
|---|---|---|---|
| Idle | ~700 ±20 Watts (694W) | ~778 ±22 Watts (assuming ~90% PSU efficiency) | Baseline power with all 8 CPUs and 1 TB RAM powered, no load. This reflects the high baseline draw typical of multi-socket enterprise servers due to memory controllers and interconnects. AC estimate accounts for conversion losses in the Platinum PSUs. |
| Work | ~1200 ±20 Watts (1154W) | ~1333 ±22 Watts (assuming ~90% PSU efficiency) | During sustained inference benchmarks. The increase is driven by CPU utilization across 64 cores, though efficiency is maintained thanks to the Platinum PSUs. AC estimate is conservative for 230V, it could be closer to ~1304W at higher efficiency levels (~92%). |
These figures highlight the system’s power efficiency relative to its age and scale—peaking at around 1200W DC (1333W AC) under load is reasonable for an 8-socket setup running a massive MoE model.
Test Prompts and Model Intelligence
To evaluate the intelligence and adaptability of the GLM-4.7 model running on our optimized CPU-only setup, we tested it with complex creative prompts that typically involve code generation (e.g., building interactive applications in HTML/JS). These tests assess the model’s ability to understand requirements, reason about mechanics, and adapt to constraints. All tests were run on the Lenovo System x3950 X6 with the final configuration (64 threads, NUMA distribute, etc.), demonstrating real-time inference at ~5 tokens/second.
The model showed strong reasoning, simulating game logic in text form rather than outputting executable code, which highlights its understanding of user intent while avoiding direct code generation (potentially due to built-in safeguards or prompt interpretation). Below are the exact prompts and the model’s outputs.
(Output 1: https://postl.ai/output/game.html and Output2: https://postl.ai/output/videoeditor.html for „Intelligence demonstration“)
Prompt1: Make an isometric 3D city builder, like SIM city, where i can build buildings, roads, infrastructure, etc. Building options include houses, office buildings, factories, hospitals, malls, roads, parks, etc. Revenue is generated through a custom tax slider, but high rates trigger lower happiness. Player must keep the „Global Happiness“ meter above 95% by building more malls, parks, healthcare, etc. Make the game look amazing. Put everything in a standalone html file
Output1:
Here is a complete, standalone HTML file containing the isometric 3D city builder game. It includes all game logic, CSS 3D rendering, and UI within a single document.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IsoCity 3D Builder</title>
<style>
:root {
--bg-sky: #87CEEB;
--bg-ground: #e0f7fa;
--tile-size: 50px;
--grid-gap: 2px;
--c-grass: #81C784;
--c-road: #546E7A;
--c-residential: #FF8A65; /* House */
--c-commercial: #42A5F5; /* Office/Mall */
--c-industrial: #78909C; /* Factory */
--c-service: #66BB6A; /* Park/Hospital */
--ui-bg: rgba(255, 255, 255, 0.9);
--ui-shadow: 0 4px 15px rgba(0,0,0,0.2);
--primary: #2c3e50;
--accent: #e74c3c;
}
* { margin: 0; padding: 0; box-sizing: border-box; user-select: none; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(to bottom, #87CEEB 0%, #E0F7FA 100%);
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
color: var(--primary);
}
/* --- UI Overlay --- */
header {
padding: 15px 20px;
background: var(--ui-bg);
backdrop-filter: blur(5px);
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: var(--ui-shadow);
z-index: 100;
}
.stats-bar {
display: flex;
gap: 20px;
font-weight: bold;
font-size: 1.1rem;
}
.stat-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 12px;
border-radius: 20px;
background: rgba(0,0,0,0.05);
}
.stat-value { color: var(--accent); }
.stat-happy.low { color: red; }
.stat-happy.high { color: green; }
/* --- Game Viewport --- */
#game-container {
flex: 1;
position: relative;
perspective: 1000px;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
cursor: grab;
}
#game-container:active { cursor: grabbing; }
#world {
position: relative;
transform-style: preserve-3d;
transform: rotateX(60deg) rotateZ(-45deg);
transition: transform 0.1s ease-out;
width: 0; height: 0; /* Center point */
}
/* --- Tiles & Buildings --- */
.tile {
position: absolute;
width: var(--tile-size);
height: var(--tile-size);
background-color: var(--c-grass);
border: 1px solid rgba(0,0,0,0.05);
transform-origin: center center;
transition: transform 0.2s, filter 0.2s;
cursor: pointer;
box-shadow: inset 0 0 10px rgba(0,0,0,0.1);
}
.tile:hover {
filter: brightness(1.2);
z-index: 100 !important;
}
/* Isometric Faces for Buildings */
.building {
position: absolute;
bottom: 100%;
left: 0;
width: 100%;
height: 100%;
transform-style: preserve-3d;
pointer-events: none; /* Let clicks pass to tile */
}
.building-top {
position: absolute;
width: 100%;
height: 100%;
background: inherit;
filter: brightness(1.1);
transform: translateZ(var(--height));
}
.building-side-right {
position: absolute;
width: 100%;
height: var(--height);
background: inherit;
filter: brightness(0.8);
transform-origin: bottom;
transform: rotateX(-90deg) translateY(var(--height));
}
.building-side-left {
position: absolute;
width: 100%;
height: var(--height);
background: inherit;
filter: brightness(0.6);
transform-origin: bottom left;
transform: rotateY(90deg) rotateX(-90deg) translateX(-100%);
}
/* Specific Building Styles */
.type-road { background-color: var(--c-road); --height: 2px; }
.type-house { background-color: var(--c-residential); --height: 30px; }
.type-office { background-color: var(--c-commercial); --height: 60px; }
.type-factory { background-color: var(--c-industrial); --height: 40px; }
.type-hospital { background-color: white; --height: 45px; border: 2px solid red; }
.type-mall { background-color: purple; --height: 50px; }
.type-park { background-color: #4CAF50; --height: 5px; }
.type-water { background-color: #29B6F6; --height: 2px; }
/* --- Controls Panel --- */
#controls {
background: var(--ui-bg);
backdrop-filter: blur(10px);
padding: 15px;
display: flex;
justify-content: center;
gap: 20px;
box-shadow: 0 -4px 20px rgba(0,0,0,0.1);
flex-wrap: wrap;
z-index: 100;
}
.control-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
background: rgba(255,255,255,0.5);
padding: 10px;
border-radius: 12px;
}
.group-title {
font-size: 0.8rem;
text-transform: uppercase;
color: #666;
letter-spacing: 1px;
font-weight: bold;
}
.tools-grid {
display: flex;
gap: 8px;
}
.tool-btn {
width: 50px;
height: 50px;
border: 2px solid transparent;
border-radius: 10px;
background: white;
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
font-size: 10px;
transition: all 0.2s;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
position: relative;
}
.tool-btn svg { width: 24px; height: 24px; margin-bottom: 2px; fill: #555; }
.tool-btn.active { border-color: var(--accent); background: #fff0f0; transform: translateY(-2px); }
.tool-btn:hover { transform: scale(1.05); }
.cost-badge {
position: absolute;
top: -5px;
right: -5px;
background: #333;
color: #fff;
font-size: 9px;
padding: 2px 4px;
border-radius: 4px;
}
/* --- Slider --- */
.slider-container {
width: 150px;
display: flex;
flex-direction: column;
align-items: center;
}
input[type=range] {
width: 100%;
cursor: pointer;
}
/* --- Toasts & Modals --- */
#toast-container {
position: absolute;
top: 80px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: none;
}
.toast {
background: rgba(0,0,0,0.8);
color: white;
padding: 10px 20px;
border-radius: 20px;
font-size: 0.9rem;
animation: fadeUp 2s forwards;
}
@keyframes fadeUp {
0% { opacity: 0; transform: translateY(10px); }
10% { opacity: 1; transform: translateY(0); }
80% { opacity: 1; }
100% { opacity: 0; transform: translateY(-10px); }
}
.modal {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.6);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 30px;
border-radius: 20px;
max-width: 500px;
text-align: center;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
.modal h2 { margin-bottom: 15px; color: var(--primary); }
.modal p { margin-bottom: 20px; line-height: 1.5; color: #555; }
.btn-primary {
background: var(--accent);
color: white;
border: none;
padding: 12px 30px;
font-size: 1rem;
border-radius: 25px;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover { background: #c0392b; }
.hidden { display: none !important; }
/* Tutorial highlight */
.highlight {
animation: pulse 1s infinite alternate;
}
@keyframes pulse {
from { box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); }
to { box-shadow: 0 0 0 10px rgba(255, 255, 255, 0); }
}
</style>
</head>
<body>
<header>
<div class="logo">
<h1>IsoCity 3D</h1>
</div>
<div class="stats-bar">
<div class="stat-item">
<span>Funds:</span>
<span class="stat-value" id="money-display">$5,000</span>
</div>
<div class="stat-item">
<span>Pop:</span>
<span class="stat-value" id="pop-display">0</span>
</div>
<div class="stat-item">
<span>Happy:</span>
<span class="stat-value stat-happy" id="happy-display">100%</span>
</div>
<div class="stat-item">
<span>Day:</span>
<span class="stat-value" id="day-display">1</span>
</div>
</div>
</header>
<main id="game-container">
<div id="world">
<!-- Grid generated by JS -->
</div>
</main>
<div id="toast-container"></div>
<section id="controls">
<div class="control-group">
<span class="group-title">Infrastructure</span>
<div class="tools-grid">
<button class="tool-btn" onclick="selectTool('road')">
<span class="cost-badge">$10</span>
<svg viewBox="0 0 24 24"><path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM6 4h5v8l-2.5-1.5L6 12V4z"/></svg>
Road
</button>
<button class="tool-btn" onclick="selectTool('bulldoze')">
<span class="cost-badge">$5</span>
<svg viewBox="0 0 24 24"><path d="M16 9v10H8V9h8m-1.5-6h-5l-1 1H5v2h14V4h-3.5l-1-1zM18 7H6v12c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7z"/></svg>
Clear
</button>
</div>
</div>
<div class="control-group">
<span class="group-title">Zones</span>
<div class="tools-grid">
<button class="tool-btn" onclick="selectTool('house')">
<span class="cost-badge">$100</span>
<svg viewBox="0 0 24 24"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>
House
</button>
<button class="tool-btn" onclick="selectTool('office')">
<span class="cost-badge">$300</span>
<svg viewBox="0 0 24 24"><path d="M15 11V5l-3-3-3 3v2H3v14h18V11h-6zm-8 8H5v-2h2v2zm0-4H5v-2h2v2zm0-4H5V9h2v2zm6 8h-2v-2h2v2zm0-4h-2v-2h2v2zm0-4h-2V9h2v2zm0-4h-2V5h2v2zm6 12h-2v-2h2v2zm0-4h-2v-2h2v2z"/></svg>
Office
</button>
<button class="tool-btn" onclick="selectTool('factory')">
<span class="cost-badge">$500</span>
<svg viewBox="0 0 24 24"><path d="M22 22H2V11l5-5v5h2l5 5 5-5 3 3v8zm-3-8l-3-3-4 4h-2V6H2L11 1l7 5v8z"/></svg>
Factory
</button>
</div>
</div>
<div class="control-group">
<span class="group-title">Services</span>
<div class="tools-grid">
<button class="tool-btn" onclick="selectTool('park')">
<span class="cost-badge">$150</span>
<svg viewBox="0 0 24 24"><path d="M17 8c.7 0 1.38.1 2 .29V5l-6 3-6-3v3.29c.62-.19 1.3-.29 2-.29 4.42 0 8 3.58 8 8s-3.58 8-8 8-8-3.58-8-8c0-1.88.65-3.61 1.74-4.97L5 6 2 8l2 2h-.01C1.6 11.59 0 14.58 0 18c0 3.31 2.69 6 6 6h11c3.86 0 7-3.14 7-7s-3.14-7-7-7z"/></svg>
Park
</button>
<button class="tool-btn" onclick="selectTool('mall')">
<span class="cost-badge">$600</span>
<svg viewBox="0 0 24 24"><path d="M20 4H4v2h16V4zm1 10v-2l-1-5H4l-1 5v2h1v6h10v-6h4v6h2v-6h1zm-9 4H6v-4h6v4z"/></svg>
Mall
</button>
<button class="tool-btn" onclick="selectTool('hospital')">
<span class="cost-badge">$1000</span>
<svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-1 11h-4v4h-4v-4H6v-4h4V6h4v4h4v4z"/></svg>
Hospital
</button>
</div>
</div>
<div class="control-group slider-container">
<span class="group-title">Tax Rate: <span id="tax-val">5%</span></span>
<input type="range" id="tax-slider" min="0" max="20" value="5" step="1">
<small style="color:#666; margin-top:2px;">High tax lowers happiness!</small>
</div>
</section>
<!-- Welcome Modal -->
<div id="welcome-modal" class="modal">
<div class="modal-content">
<h2>Welcome to IsoCity!</h2>
<p>You are the new mayor. Build a thriving metropolis while keeping citizens happy.</p>
<p><strong>Rules:</strong></p>
<ul style="text-align: left; margin-bottom: 20px; padding-left: 20px; color: #555;">
<li>Build <strong>Houses</strong> to increase population.</li>
<li>Build <strong>Offices/Factories</strong> for jobs (generates revenue).</li>
<li>Use the <strong>Tax Slider</strong> to collect money. High taxes make people angry!</li>
<li>Build <strong>Parks, Malls, Hospitals</strong> to boost happiness.</li>
<li>Keep <strong>Happiness above 95%</strong> or you get fired!</li>
</ul>
<button class="btn-primary" onclick="startGame()">Start Building</button>
</div>
</div>
<!-- Game Over Modal -->
<div id="game-over-modal" class="modal hidden">
<div class="modal-content">
<h2 id="go-title">Game Over</h2>
<p id="go-message">The citizens have fired you.</p>
<p>Final Population: <span id="go-pop">0</span></p>
<p>Days Survived: <span id="go-days">0</span></p>
<button class="btn-primary" onclick="location.reload()">Play Again</button>
</div>
</div>
<script>
/* --- Game Configuration & State --- */
const GRID_SIZE = 12;
const TILE_SIZE = 50;
const TICK_RATE = 2000; // 1 day every 2 seconds
const BUILDINGS = {
'road': { cost: 10, type: 'road', color: 'var(--c-road)', pop: 0, happy: 0, income: 0 },
'house': { cost: 100, type: 'house', color: 'var(--c-residential)', pop: 5, happy: 0, income: 0 },
'office': { cost: 300, type: 'office', color: 'var(--c-commercial)', pop: 0, happy: 0, income: 15 },
'factory': { cost: 500, type: 'factory', color: 'var(--c-industrial)', pop: 0, happy: -2, income: 30 },
'park': { cost: 150, type: 'park', color: '#4CAF50', pop: 0, happy: 3, income: -2 },
'mall': { cost: 600, type: 'mall', color: 'purple', pop: 0, happy: 5, income: 10 },
'hospital': { cost: 1000, type: 'hospital', color: 'white', pop: 0, happy: 4, income: -5 },
'bulldoze': { cost: 5, type: 'empty', color: 'var(--c-grass)', pop: 0, happy: 0, income: 0 }
};
let state = {
money: 5000,
population: 0,
happiness: 100,
day: 1,
taxRate: 0.05, // 5%
grid: [], // 2D array storing tile data
selectedTool: null
};
let gameInterval;
/* --- Initialization --- */
function initGrid() {
const world = document.getElementById('world');
world.innerHTML = '';
state.grid = [];
for (let x = 0; x < GRID_SIZE; x++) {
let row = [];
for (let z = 0; z < GRID_SIZE; z++) {
// Data model
row.push({ type: 'empty', building: null });
// DOM Element
const tile = document.createElement('div');
tile.className = 'tile';
tile.dataset.x = x;
tile.dataset.z = z;
// Positioning (Isometric logic)
// In CSS 3D rotated 45deg X, moving x is x+y, moving y (depth) is x-y visually
// But with standard CSS transform logic on parent:
// We position them normally in a square grid, then rotate the container.
const left = x * (TILE_SIZE + 2); // +2 gap
const top = z * (TILE_SIZE + 2);
tile.style.left = `${left}px`;
tile.style.top = `${top}px`;
// Interaction
tile.onclick = () => handleTileClick(x, z);
// Z-Index sorting: items further back (higher x + z) must be drawn later?
// Actually, in isometric 60/45, usually (x+z) determines draw order.
tile.style.zIndex = x + z;
world.appendChild(tile);
}
state.grid.push(row);
}
// Center the grid
const totalWidth = GRID_SIZE * TILE_SIZE;
const totalHeight = GRID_SIZE * TILE_SIZE;
world.style.width = `${totalWidth}px`;
world.style.height = `${totalHeight}px`;
}
function startGame() {
document.getElementById('welcome-modal').classList.add('hidden');
initGrid();
updateUI();
gameInterval = setInterval(gameTick, TICK_RATE);
}
/* --- Interaction --- */
function selectTool(toolName) {
// UI Toggle
document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active'));
if (toolName) {
const btn = document.querySelector(`button[onclick="selectTool('${toolName}')"]`);
if(btn) btn.classList.add('active');
state.selectedTool = toolName;
} else {
state.selectedTool = null;
}
}
function handleTileClick(x, z) {
if (!state.selectedTool) return;
const tileData = state.grid[x][z];
const toolInfo = BUILDINGS[state.selectedTool];
// Check funds
if (state.money < toolInfo.cost) {
showToast("Not enough funds!", "error");
return;
}
// Logic for bulldoze vs build
if (state.selectedTool === 'bulldoze') {
if (tileData.type !== 'empty') {
tileData.type = 'empty';
updateTileVisual(x, z);
state.money -= toolInfo.cost;
playSound('demolish');
calculateStats(); // Immediate update
updateUI();
createFloatingText(x, z, `-$${toolInfo.cost}`, 'red');
}
} else {
// Build
if (tileData.type !== 'empty') {
showToast("Space occupied!", "error");
return;
}
if (tileData.type === 'road' && state.selectedTool !== 'road') {
// Allow roads on roads (upgrade?), but usually blocking is fine
}
tileData.type = state.selectedTool;
updateTileVisual(x, z);
state.money -= toolInfo.cost;
calculateStats();
updateUI();
createFloatingText(x, z, `-$${toolInfo.cost}`, 'orange');
}
}
/* --- Rendering --- */
function updateTileVisual(x, z) {
// Find the specific DOM element
const world = document.getElementById('world');
// We use nth-child logic or dataset, but simpler to just map index
const index = x * GRID_SIZE + z;
const tileEl = world.children[index];
const data = state.grid[x][z];
// Reset content
tileEl.className = 'tile';
tileEl.innerHTML = ''; // Remove building block
if (data.type !== 'empty') {
tileEl.classList.add(`type-${data.type}`);
// Create 3D Building Block
const b = document.createElement('div');
b.className = 'building';
const top = document.createElement('div');
top.className = 'building-top';
const sideR = document.createElement('div');
sideR.className = 'building-side-right';
const sideL = document.createElement('div');
sideL.className = 'building-side-left';
b.appendChild(sideL);
b.appendChild(sideR);
b.appendChild(top);
tileEl.appendChild(b);
// Pop animation
b.style.transform = "scale(0)";
setTimeout(() => b.style.transform = "scale(1)", 10);
}
}
/* --- Game Logic --- */
// Tax Slider Logic
const taxSlider = document.getElementById('tax-slider');
const taxValDisplay = document.getElementById('tax-val');
taxSlider.addEventListener('input', (e) => {
const val = parseInt(e.target.value);
state.taxRate = val / 100;
taxValDisplay.innerText = val + "%";
// Visual feedback on happiness calculation
calculateStats();
updateUI();
});
function gameTick() {
state.day++;
// Collect Taxes
const taxIncome = Math.floor(state.population * 10 * state.taxRate);
state.money += taxIncome;
if(taxIncome > 0) {
// Show global income toast occasionally or just UI update
// createFloatingText(GRID_SIZE/2, GRID_SIZE/2, `+Tax $${taxIncome}`, 'green'); // Too spammy
}
calculateStats();
checkWinLoss();
updateUI();
}
function calculateStats() {
let totalPop = 0;
let happyBase = 100;
let jobCapacity = 0;
// Iterate grid
for (let x = 0; x < GRID_SIZE; x++) {
for (let z = 0; z < GRID_SIZE; z++) {
const type = state.grid[x][z].type;
if (type === 'empty') continue;
const info = BUILDINGS[type];
totalPop += info.pop;
jobCapacity += (info.income > 0 ? info.pop : 0); // Simplified: offices/factories provide jobs
// Happiness effects
happyBase += info.happy;
}
}
// Tax Penalty
// 0% tax = 0 penalty, 20% tax = -40 happiness (severe)
const taxPenalty = (state.taxRate * 100) * 2.5;
happyBase -= taxPenalty;
// Unemployment penalty
if (totalPop > 0) {
// Assuming 1 job per worker roughly.
// If pop > jobs, unhappiness.
// This is simplified.
}
// Clamp happiness
state.population = totalPop;
state.happiness = Math.max(0, Math.min(100, Math.floor(happyBase)));
}
function checkWinLoss() {
if (state.happiness < 90) {
showToast("Warning: Happiness is dropping!", "warning");
}
if (state.happiness < 80) {
gameOver("Citizens are unhappy! You were fired.");
}
}
function gameOver(reason) {
clearInterval(gameInterval);
document.getElementById('go-message').innerText = reason;
document.getElementById('go-pop').innerText = state.population;
document.getElementById('go-days').innerText = state.day;
document.getElementById('game-over-modal').classList.remove('hidden');
}
function updateUI() {
document.getElementById('money-display').innerText = `$${state.money.toLocaleString()}`;
document.getElementById('pop-display').innerText = state.population.toLocaleString();
const happyEl = document.getElementById('happy-display');
happyEl.innerText = state.happiness + "%";
happyEl.className = 'stat-value stat-happy ' + (state.happiness > 90 ? 'high' : 'low');
document.getElementById('day-display').innerText = state.day;
}
/* --- Visual FX --- */
function showToast(msg, type) {
const container = document.getElementById('toast-container');
const el = document.createElement('div');
el.className = 'toast';
el.innerText = msg;
if(type === 'error') el.style.borderLeft = "4px solid red";
if(type === 'warning') el.style.borderLeft = "4px solid orange";
container.appendChild(el);
setTimeout(() => el.remove(), 2500);
}
function createFloatingText(x, z, text, color) {
const world = document.getElementById('world');
const index = x * GRID_SIZE + z;
const tile = world.children[index];
const floatEl = document.createElement('div');
floatEl.innerText = text;
floatEl.style.position = 'absolute';
floatEl.style.color = color;
floatEl.style.fontWeight = 'bold';
floatEl.style.fontSize = '14px';
floatEl.style.textShadow = '1px 1px 0 #000';
floatEl.style.pointerEvents = 'none';
floatEl.style.zIndex = 200;
// We need to position it relative to the viewport because of 3D transforms making it hard to append to tile
const rect = tile.getBoundingClientRect();
floatEl.style.left = rect.left + 'px';
floatEl.style.top = rect.top + 'px';
document.body.appendChild(floatEl);
// Animate
let opacity = 1;
let top = rect.top - 20;
const anim = setInterval(() => {
opacity -= 0.05;
top -= 1;
floatEl.style.opacity = opacity;
floatEl.style.top = top + 'px';
if(opacity <= 0) {
clearInterval(anim);
floatEl.remove();
}
}, 30);
}
function playSound(type) {
// Placeholder for sound effects.
// In a real game we would use Audio() objects here.
}
/* --- Input Handling for Pan/Zoom --- */
let isDragging = false;
let startX, startY;
let transX = 0, transY = 0;
const container = document.getElementById('game-container');
container.addEventListener('mousedown', (e) => {
if(e.target.closest('.tile')) return; // Don't drag if clicking tile
isDragging = true;
startX = e.clientX - transX;
startY = e.clientY - transY;
});
window.addEventListener('mouseup', () => isDragging = false);
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
e.preventDefault();
transX = e.clientX - startX;
transY = e.clientY - startY;
updateWorldTransform();
});
// Simple Zoom with wheel
container.addEventListener('wheel', (e) => {
e.preventDefault();
const currentScale = 1; // Can make this dynamic
// Just a placeholder for zoom logic if needed later
// For this version, we keep scale fixed to ensure isometric alignment stays simple
});
function updateWorldTransform() {
const world = document.getElementById('world');
// Center the world initially then apply offset
world.style.marginLeft = `${transX}px`;
world.style.marginTop = `${transY}px`;
}
</script>
</body>
</html>
Prompt 2:
make a video editor with multiple tracks on the timeline, and a media library. allow me to upload any clip and drag/reposition it in the timeline. Put everything in a standalone html file
Output2:
Here’s a thinking process that leads to the provided video editor solution:
- Analyze the User’s Request:
- Goal: Create a video editor with multiple tracks, a media library, and drag-and-drop functionality.
- Features: Upload clips, drag/reposition clips on the timeline.
- Constraint: Single standalone HTML file.
- Role: Advanced Front-End Engineer & 2D Web Interaction Specialist.
- Determine Feasibility & Scope (Crucial Step):
- Can a single HTML file create a fully functional non-linear video editor (NLE) like Premiere Pro? No. Real video editing requires backend processing (FFmpeg, etc.) or very heavy WebAssembly libraries (ffmpeg.wasm) which are too large to embed in a single text-based HTML response.
- What CAN be done? A functional UI/UX prototype that simulates the experience.
- Simulation Strategy:
- Media Library: Use the File API to read local files, create object URLs (
URL.createObjectURL), and display thumbnails (using<video>elements). - Timeline: Use HTML5 Canvas or CSS Grid/Flexbox with absolute positioning for the „tracks.“ CSS Grid/Flexbox is easier for DOM manipulation of „clips.“ Let’s go with a container-based approach where clips are
divelements representing time blocks. - Playback: Use the HTML5
<video>element. To „edit,“ we won’t actually cut the video file. Instead, we will manage a list of „virtual clips“ with start/end times and source offsets. The player will seek to the specific timestamp of the source video when the timeline playhead hits a clip. - Drag & Drop: Implement standard HTML5 Drag and Drop API or mouse event handling (mousedown, mousemove, mouseup) for smoother timeline manipulation. Mouse events are better for precise timeline resizing and moving.
- Media Library: Use the File API to read local files, create object URLs (
- Architectural Design:
- Layout:
- Header: Title, basic controls (Export – mock).
- Main Workspace:
- Left Panel: Media Library (Upload button, list of imported assets).
- Center Panel: Preview Player (The visual output).
- Bottom Panel: Timeline (Tracks, Playhead, Time ruler).
- Data Structure (State):
assets: Array of file objects { id, url, name, duration }.tracks: Array of arrays (Track 1: [Clip A, Clip B], Track 2: []).clips: Objects { id, assetId, trackId, startTime (on timeline), offset (start time within source), duration }.currentTime: The playhead position in seconds.isPlaying: Boolean.
- Visual Style: Dark mode (industry standard for video editors), sleek, high contrast, distinct track colors.
- Layout:
- Step-by-Step Implementation Plan:
- Step 1: HTML Structure: Setup the grid layout.
- Step 2: CSS Styling: Dark theme, flexbox/grid for layout, styling the timeline tracks and clips. Custom scrollbars.
- Step 3: JavaScript – State Management: Basic variables to hold the timeline state.
- Step 4: JavaScript – Media Library: Handle file input, generate thumbnails, store assets.
- Step 5: JavaScript – Timeline Rendering: Function to draw tracks and clips based on the state.
- Step 6: JavaScript – Drag & Drop Logic (The Hard Part):
- Dragging from Library to Timeline (Create new clip).
- Moving clips on Timeline (Update
startTime). - Resizing clips (Update
duration).
- Step 7: JavaScript – Playback Engine:
requestAnimationFrameloop to update the playhead and switch the source video element based on which clip is under the playhead.
- Refining the „Video Editing“ Logic (Simulation):
- Since we can’t render a new video file client-side easily, the „Preview“ will be the most honest representation.
- Logic: On every frame of playback, check
currentTime. - Find active clip on the top-most track at
currentTime. - If found: Set
video.srcto that clip’s asset. Setvideo.currentTimetoclip.offset+ (currentTime–clip.startTime). Play. - If not found: Show black screen or last frame.
- Constraint Check: Switching sources rapidly might cause flickering or loading delays. I will add a „buffer“ visual or just accept that it’s a prototype limitation. To make it smoother, I’ll try to keep the same video element if the asset is the same.
- Drafting the Code (Mental or Scratchpad):
- Timeline Scale: 1 second = 50 pixels (configurable).
- Clip DOM:
<div class="clip" style="left: ...px; width: ...px;">Name</div>. - Drag Logic:
mousedownon clip: record start X, start clip time.mousemove: calculate delta X, convert to time, update visual position.mouseup: finalize state, re-render.
- Writing the Actual Solution (Iterative Refinement):
- Self-Correction during coding: HTML5 Drag and Drop is clunky for precise timeline resizing. I will use custom Mouse Events (
mousedown,window.mousemove,window.mouseup) for the timeline interactions to ensure a „pro“ feel. - Handling Audio: For simplicity in this specific request, I’ll focus on visual tracks. I’ll add „Audio Track“ visual placeholders but the core logic will handle the video preview.
- Export: I’ll implement a „Simulate Export“ button that shows a toast notification, explaining that real rendering requires a backend.
- Self-Correction during coding: HTML5 Drag and Drop is clunky for precise timeline resizing. I will use custom Mouse Events (
- Final Polish:
- Add tooltips or instructions.
- Ensure the playhead moves smoothly.
- Add zoom functionality (scale factor).
- Make sure the file uploader accepts video formats.
- Icons: Use SVG strings directly in HTML to avoid external dependencies or broken links.
- Review against Constraints:
- Single file? Yes.
- No frameworks? Yes, vanilla JS.
- No alerts? Yes, use a custom toast function.
- Interactive? Yes, drag/drop, play/pause, seek.
- Final Code Structure Construction: (This matches the provided good response).
- CSS: Variables for colors, Grid layout, specific styles for
.timeline-track,.clip,.playhead. - JS: Class-based structure or modular functions. Let’s use a module-like structure for clarity (
App,Timeline,Player,Library).
- CSS: Variables for colors, Grid layout, specific styles for
(Self-Correction during the process):
Issue: How to handle multiple clips on the same track overlapping?
Decision: For this lightweight tool, I will allow overlaps visually (z-index), but the playback engine will prioritize the „top“ track (lower index in array) or the one currently active. Actually, standard NLEs don’t allow same-track overlaps easily without ripple editing. I’ll allow them for flexibility but the player will just pick the first one found.
Issue: Performance.
Decision: Use transform: translateX(...) for the playhead and clips during drag for 60fps performance, then update left/width styles on drop.
- Final Output Generation (Proceed to generate the HTML string).
</think>Here is a complete, single-file HTML solution for a web-based Video Editor.
This application features a dark-mode professional UI, a functional media library (using local file access), a multi-track timeline with drag-and-drop capabilities, and a playback engine that stitches the „virtual“ edits together in real-time.
How to use it:
- Upload: Click „Import Media“ to add video files from your computer.
- Add to Timeline: Drag an item from the library onto any track in the timeline.
- Edit:
- Move: Drag the center of a clip to move it along the timeline.
- Trim: Drag the right edge of a clip to change its duration.
- Ripple Delete (Mock): Click the „X“ on a clip to remove it.
- Playback: Use the spacebar or the play button to watch your sequence. The playhead will scrub through the video sources automatically.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pro Web Video Editor</title>
<style>
:root {
--bg-dark: #121212;
--bg-panel: #1e1e1e;
--bg-header: #252525;
--border: #333;
--primary: #3b82f6;
--primary-hover: #2563eb;
--text-main: #e5e5e5;
--text-muted: #a3a3a3;
--track-1: #1e3a8a;
--track-2: #3f6212;
--track-3: #7f1d1d;
--playhead: #ef4444;
--ruler-text: #666;
}
* {
box-sizing: border-box;
user-select: none;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: var(--bg-dark);
color: var(--text-main);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* --- Header --- */
header {
height: 50px;
background-color: var(--bg-header);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 20px;
justify-content: space-between;
}
.logo {
font-weight: 700;
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 10px;
}
.header-controls {
display: flex;
gap: 10px;
}
button {
background-color: var(--bg-panel);
border: 1px solid var(--border);
color: var(--text-main);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
button:hover {
background-color: #333;
border-color: #555;
}
button.primary {
background-color: var(--primary);
border-color: var(--primary);
color: white;
}
button.primary:hover {
background-color: var(--primary-hover);
}
/* --- Main Layout --- */
main {
flex: 1;
display: flex;
overflow: hidden;
}
/* --- Left: Media Library --- */
.panel-left {
width: 300px;
background-color: var(--bg-panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.panel-header {
padding: 10px 15px;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
font-weight: 600;
display: flex;
justify-content: space-between;
align-items: center;
}
.media-grid {
flex: 1;
padding: 10px;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
align-content: start;
}
.media-item {
background-color: #2a2a2a;
border-radius: 6px;
overflow: hidden;
cursor: grab;
transition: transform 0.1s;
border: 1px solid transparent;
position: relative;
}
.media-item:hover {
border-color: var(--primary);
transform: translateY(-2px);
}
.media-item:active {
cursor: grabbing;
}
.media-thumb {
width: 100%;
height: 80px;
object-fit: cover;
background: #000;
}
.media-info {
padding: 6px;
font-size: 0.75rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* --- Center: Preview --- */
.panel-center {
flex: 1;
display: flex;
flex-direction: column;
background-color: #000;
position: relative;
}
.preview-area {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
video#main-player {
max-width: 100%;
max-height: 100%;
box-shadow: 0 0 20px rgba(0,0,0,0.5);
}
.transport-controls {
height: 50px;
background-color: var(--bg-panel);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
}
.time-display {
font-family: monospace;
font-size: 0.9rem;
color: var(--text-muted);
}
/* --- Bottom: Timeline --- */
.panel-bottom {
height: 350px;
background-color: var(--bg-panel);
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.timeline-toolbar {
height: 40px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 10px;
gap: 10px;
}
.timeline-container {
flex: 1;
position: relative;
overflow-x: auto;
overflow-y: auto;
background-color: #181818;
}
/* Ruler */
.time-ruler {
height: 25px;
background-color: #222;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
cursor: pointer;
min-width: 100%;
}
/* Tracks Area */
.tracks-area {
position: relative;
padding-top: 5px;
min-width: 100%;
}
.track {
height: 60px;
background-color: #252525;
border-bottom: 1px solid var(--border);
position: relative;
margin-bottom: 2px;
}
.track::before {
content: attr(data-label);
position: sticky;
left: 0;
top: 0;
background: #333;
color: #ccc;
font-size: 0.7rem;
padding: 2px 5px;
z-index: 5;
border-radius: 0 4px 4px 0;
pointer-events: none;
}
/* Clips */
.clip {
position: absolute;
height: 50px;
top: 5px;
border-radius: 4px;
background-color: #444;
border: 1px solid rgba(255,255,255,0.1);
cursor: grab;
overflow: hidden;
display: flex;
align-items: center;
padding: 0 8px;
font-size: 0.75rem;
color: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
min-width: 10px;
}
.clip:active {
cursor: grabbing;
}
.clip-handle {
position: absolute;
top: 0;
bottom: 0;
width: 8px;
cursor: col-resize;
background: rgba(255,255,255,0.1);
}
.clip-handle:hover {
background: rgba(255,255,255,0.3);
}
.clip-handle.right {
right: 0;
}
.clip-remove {
position: absolute;
top: 2px;
right: 4px;
width: 14px;
height: 14px;
background: rgba(0,0,0,0.5);
border-radius: 50%;
display: none;
align-items: center;
justify-content: center;
font-size: 10px;
cursor: pointer;
}
.clip:hover .clip-remove {
display: flex;
}
/* Playhead */
.playhead-container {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
pointer-events: none;
z-index: 20;
}
.playhead-line {
width: 2px;
height: 100%;
background-color: var(--playhead);
}
.playhead-top {
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 8px solid var(--playhead);
position: absolute;
top: 0;
left: -5px;
}
/* Toast Notification */
#toast {
position: fixed;
bottom: 20px;
right: 20px;
background-color: #333;
color: #fff;
padding: 12px 24px;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
transform: translateY(100px);
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
z-index: 1000;
border-left: 4px solid var(--primary);
}
#toast.visible {
transform: translateY(0);
}
/* Hidden file input */
#file-input { display: none; }
</style>
</head>
<body>
<header>
<div class="logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"></rect>
<line x1="7" y1="2" x2="7" y2="22"></line>
<line x1="17" y1="2" x2="17" y2="22"></line>
<line x1="2" y1="12" x2="22" y2="12"></line>
<line x1="2" y1="7" x2="7" y2="7"></line>
<line x1="2" y1="17" x2="7" y2="17"></line>
<line x1="17" y1="17" x2="22" y2="17"></line>
<line x1="17" y1="7" x2="22" y2="7"></line>
</svg>
WebCut Pro
</div>
<div class="header-controls">
<button onclick="app.clearTimeline()">Clear Project</button>
<button class="primary" onclick="app.exportProject()">Export Video</button>
</div>
</header>
<main>
<!-- Left Panel: Media Library -->
<div class="panel-left">
<div class="panel-header">
<span>Media Library</span>
<button onclick="document.getElementById('file-input').click()">+ Import</button>
<input type="file" id="file-input" accept="video/*,image/*" multiple onchange="app.handleFiles(this.files)">
</div>
<div class="media-grid" id="media-library">
<!-- Media items injected here -->
<div style="grid-column: span 2; text-align: center; color: var(--text-muted); padding: 20px; font-size: 0.8rem;">
Drag & drop video files here or click Import
</div>
</div>
</div>
<!-- Center Panel: Preview -->
<div class="panel-center">
<div class="preview-area">
<video id="main-player" playsinline></video>
</div>
<div class="transport-controls">
<button onclick="app.skip(-5)">-5s</button>
<button id="play-btn" class="primary" onclick="app.togglePlay()">Play</button>
<button onclick="app.skip(5)">+5s</button>
<span class="time-display" id="time-display">00:00.0 / 00:00.0</span>
</div>
</div>
<!-- Bottom Panel: Timeline -->
<div class="panel-bottom">
<div class="timeline-toolbar">
<button onclick="app.addTrack()">+ Add Track</button>
<div style="flex:1"></div>
<button onclick="app.zoom(0.8)">Zoom Out</button>
<button onclick="app.zoom(1.2)">Zoom In</button>
</div>
<div class="timeline-container" id="timeline-scroll-area">
<div class="time-ruler" id="time-ruler"></div>
<div class="tracks-area" id="tracks-container">
<!-- Tracks injected here -->
<div class="playhead-container" id="playhead" style="left: 0px;">
<div class="playhead-top"></div>
<div class="playhead-line"></div>
</div>
</div>
</div>
</div>
</main>
<div id="toast">Notification</div>
<script>
/**
* WebCut Pro - Core Application Logic
* Handles Media Library, Timeline State, Drag & Drop, and Playback Engine.
*/
const Utils = {
generateId: () => 'id_' + Math.random().toString(36).substr(2, 9),
formatTime: (seconds) => {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 10);
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}.${ms}`;
},
pixelsToSeconds: (px, scale) => px / scale,
secondsToPixels: (sec, scale) => sec * scale
};
class App {
constructor() {
// Configuration
this.pixelsPerSecond = 20; // Zoom level
this.timelineDuration = 600; // Visual width in seconds
// State
this.assets = []; // { id, file, url, type, duration }
this.tracks = [
{ id: 't1', clips: [] },
{ id: 't2', clips: [] },
{ id: 't3', clips: [] } // Default tracks
];
this.currentTime = 0;
this.isPlaying = false;
this.activeClip = null; // Currently playing clip data
// DOM Elements
this.mediaLibrary = document.getElementById('media-library');
this.tracksContainer = document.getElementById('tracks-container');
this.timeRuler = document.getElementById('time-ruler');
this.playhead = document.getElementById('playhead');
this.videoPlayer = document.getElementById('main-player');
this.timeDisplay = document.getElementById('time-display');
this.playBtn = document.getElementById('play-btn');
// Dragging State
this.dragState = {
type: null, // 'new-clip', 'move-clip', 'resize-clip'
assetId: null,
clipId: null,
trackId: null,
startX: 0,
startLeft: 0,
startWidth: 0,
element: null
};
this.init();
}
init() {
this.renderTracks();
this.renderRuler();
this.setupEventListeners();
// Animation Loop for playback
requestAnimationFrame(this.gameLoop.bind(this));
}
/* --- Media Library --- */
async handleFiles(files) {
for (let file of files) {
if (file.type.startsWith('video/') || file.type.startsWith('image/')) {
const url = URL.createObjectURL(file);
const asset = {
id: Utils.generateId(),
file: file,
url: url,
type: file.type,
name: file.name,
duration: 5 // Default, will update for video
};
if (file.type.startsWith('video/')) {
// Get duration
const tempVideo = document.createElement('video');
tempVideo.src = url;
await new Promise(r => tempVideo.onloadedmetadata = r);
asset.duration = tempVideo.duration;
}
this.assets.push(asset);
this.addMediaToLibrary(asset);
}
}
this.showToast(`Imported ${files.length} file(s)`);
}
addMediaToLibrary(asset) {
// Remove "empty" message if present
if (this.assets.length === 1) {
this.mediaLibrary.innerHTML = '';
}
const el = document.createElement('div');
el.className = 'media-item';
el.draggable = true;
el.dataset.assetId = asset.id;
let content = '';
if (asset.type.startsWith('video/')) {
content = `<video class="media-thumb" src="${asset.url}#t=1" preload="metadata"></video>`;
} else {
content = `<img class="media-thumb" src="${asset.url}">`;
}
el.innerHTML = `
${content}
<div class="media-info">${asset.name}</div>
`;
el.ondragstart = (e) => {
this.dragState.type = 'new-clip';
this.dragState.assetId = asset.id;
e.dataTransfer.effectAllowed = 'copy';
// Hack for drag image styling
e.dataTransfer.setData('text/plain', asset.id);
};
this.mediaLibrary.appendChild(el);
}
/* --- Timeline Rendering --- */
renderTracks() {
// Clear existing tracks (except playhead)
const existingTracks = this.tracksContainer.querySelectorAll('.track');
existingTracks.forEach(t => t.remove());
this.tracks.forEach((track, index) => {
const trackEl = document.createElement('div');
trackEl.className = 'track';
trackEl.dataset.label = index === 0 ? 'Video 1' : (index === 1 ? 'Video 2' : `Audio ${index-1}`);
trackEl.dataset.trackId = track.id;
trackEl.style.width = `${this.timelineDuration * this.pixelsPerSecond}px`;
// Allow dropping on track
trackEl.ondragover = (e) => e.preventDefault();
trackEl.ondrop = (e) => this.handleDropOnTrack(e, track.id);
// Render clips
track.clips.forEach(clip => {
const clipEl = this.createClipElement(clip, track.id);
trackEl.appendChild(clipEl);
});
this.tracksContainer.appendChild(trackEl);
});
}
createClipElement(clip, trackId) {
const asset = this.assets.find(a => a.id === clip.assetId);
const el = document.createElement('div');
el.className = 'clip';
el.dataset.clipId = clip.id;
el.style.backgroundColor = this.getTrackColor(trackId);
this.updateClipPosition(el, clip);
el.innerHTML = `
<span style="pointer-events:none; z-index:2;">${asset ? asset.name : 'Clip'}</span>
<div class="clip-handle right" data-action="resize"></div>
<div class="clip-remove" onclick="app.removeClip('${clip.id}', '${trackId}')">×</div>
`;
// Mouse Events for Moving
el.onmousedown = (e) => {
if (e.target.classList.contains('clip-handle')) return; // Handle resize separately
if (e.target.classList.contains('clip-remove')) return;
this.startDragClip(e, clip.id, trackId);
e.stopPropagation(); // Prevent track drag (if we implemented that)
};
// Mouse Events for Resizing
const resizer = el.querySelector('.clip-handle.right');
resizer.onmousedown = (e) => {
this.startResizeClip(e, clip.id, trackId);
e.stopPropagation();
};
return el;
}
updateClipPosition(el, clip) {
el.style.left = `${clip.startTime * this.pixelsPerSecond}px`;
el.style.width = `${clip.duration * this.pixelsPerSecond}px`;
}
getTrackColor(trackId) {
const idx = this.tracks.findIndex(t => t.id === trackId);
if (idx === 0) return 'var(--track-1)';
if (idx === 1) return 'var(--track-2)';
return 'var(--track-3)';
}
renderRuler() {
this.timeRuler.style.width = `${this.timelineDuration * this.pixelsPerSecond}px`;
this.timeRuler.innerHTML = '';
// Draw ticks every 1 second
for (let i = 0; i <= this.timelineDuration; i++) {
const tick = document.createElement('div');
tick.style.position = 'absolute';
tick.style.left = `${i * this.pixelsPerSecond}px`;
tick.style.top = '15px';
tick.style.height = '10px';
tick.style.borderLeft = '1px solid var(--ruler-text)';
if (i % 5 === 0) {
const label = document.createElement('span');
label.innerText = Utils.formatTime(i);
label.style.position = 'absolute';
label.style.left = '4px';
label.style.top = '-15px';
label.style.fontSize = '10px';
label.style.color = 'var(--ruler-text)';
tick.appendChild(label);
tick.style.height = '15px';
}
this.timeRuler.appendChild(tick);
}
// Click on ruler to seek
this.timeRuler.onclick = (e) => {
const rect = this.timeRuler.getBoundingClientRect();
const x = e.clientX - rect.left + this.timelineContainer.scrollLeft;
const time = Math.max(0, Utils.pixelsToSeconds(x, this.pixelsPerSecond));
this.seek(time);
};
}
/* --- Drag & Drop Logic --- */
handleDropOnTrack(e, trackId) {
e.preventDefault();
if (this.dragState.type === 'new-clip') {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left; // Relative to track start (which is 0 visually usually)
// Calculate time based on scroll position
const scrollX = this.timelineContainer.scrollLeft;
const actualX = e.clientX - rect.left + scrollX;
const startTime = Utils.pixelsToSeconds(actualX, this.pixelsPerSecond);
const asset = this.assets.find(a => a.id === this.dragState.assetId);
if (asset) {
this.addClip(trackId, asset.id, startTime, asset.duration);
}
}
this.dragState.type = null;
}
startDragClip(e, clipId, trackId) {
this.dragState = {
type: 'move-clip',
clipId: clipId,
trackId: trackId,
startX: e.clientX,
initialTime: this.getClipData(clipId, trackId).startTime,
el: e.currentTarget
};
const moveHandler = (ev) => this.onDragMove(ev);
const upHandler = () => {
window.removeEventListener('mousemove', moveHandler);
window.removeEventListener('mouseup', upHandler);
this.finalizeDrag();
};
window.addEventListener('mousemove', moveHandler);
window.addEventListener('mouseup', upHandler);
}
startResizeClip(e, clipId, trackId) {
this.dragState = {
type: 'resize-clip',
clipId: clipId,
trackId: trackId,
startX: e.clientX,
initialDuration: this.getClipData(clipId, trackId).duration,
el: e.currentTarget.parentElement
};
const moveHandler = (ev) => this.onResizeMove(ev);
const upHandler = () => {
window.removeEventListener('mousemove', moveHandler);
window.removeEventListener('mouseup', upHandler);
this.dragState.type = null;
};
window.addEventListener('mousemove', moveHandler);
window.addEventListener('mouseup', upHandler);
e.stopPropagation();
}
onDragMove(e) {
if (this.dragState.type === 'move-clip') {
const deltaX = e.clientX - this.dragState.startX;
const deltaTime = Utils.pixelsToSeconds(deltaX, this.pixelsPerSecond);
let newTime = this.dragState.initialTime + deltaTime;
if (newTime < 0) newTime = 0;
// Visual Update
this.dragState.el.style.left = `${newTime * this.pixelsPerSecond}px`;
}
}
onResizeMove(e) {
if (this.dragState.type === 'resize-clip') {
const deltaX = e.clientX - this.dragState.startX;
const deltaTime = Utils.pixelsToSeconds(deltaX, this.pixelsPerSecond);
let newDuration = this.dragState.initialDuration + deltaTime;
if (newDuration < 0.5) newDuration = 0.5; // Min clip duration
// Visual Update
this.dragState.el.style.width = `${newDuration * this.pixelsPerSecond}px`;
}
}
finalizeDrag() {
if (this.dragState.type === 'move-clip') {
// Get current visual left position
const currentLeft = parseFloat(this.dragState.el.style.left);
const newTime = Utils.pixelsToSeconds(currentLeft, this.pixelsPerSecond);
// Update Data
const clip = this.getClipData(this.dragState.clipId, this.dragState.trackId);
if (clip) clip.startTime = newTime;
}
this.dragState.type = null;
}
/* --- Timeline Data Management --- */
getClipData(clipId, trackId) {
const track = this.tracks.find(t => t.id === trackId);
return track ? track.clips.find(c => c.id === clipId) : null;
}
addClip(trackId, assetId, startTime, duration) {
const track = this.tracks.find(t => t.id === trackId);
if (track) {
const clip = {
id: Utils.generateId(),
assetId: assetId,
startTime: startTime,
offset: 0, // Start point in source video
duration: duration
};
track.clips.push(clip);
this.renderTracks(); // Re-render to show new clip
}
}
removeClip(clipId, trackId) {
const track = this.tracks.find(t => t.id === trackId);
if (track) {
track.clips = track.clips.filter(c => c.id !== clipId);
this.renderTracks();
}
}
addTrack() {
const id = 't' + (this.tracks.length + 1);
this.tracks.push({ id: id, clips: [] });
this.renderTracks();
}
clearTimeline() {
this.tracks.forEach(t => t.clips = []);
this.currentTime = 0;
this.seek(0);
this.renderTracks();
this.showToast("Timeline Cleared");
}
/* --- Playback Engine --- */
togglePlay() {
this.isPlaying = !this.isPlaying;
this.playBtn.innerText = this.isPlaying ? "Pause" : "Play";
if (this.isPlaying) {
this.videoPlayer.play();
} else {
this.videoPlayer.pause();
}
}
seek(time) {
this.currentTime = time;
this.updatePlayhead();
this.updatePlayerSource();
}
skip(seconds) {
this.seek(Math.max(0, this.currentTime + seconds));
}
updatePlayhead() {
const px = this.currentTime * this.pixelsPerSecond;
this.playhead.style.left = `${px}px`;
// Auto scroll timeline if playhead goes out of view
const containerWidth = this.timelineContainer.clientWidth;
const scrollPos = this.timelineContainer.scrollLeft;
if (px > scrollPos + containerWidth - 50) {
this.timelineContainer.scrollLeft = px - 50;
} else if (px < scrollPos) {
this.timelineContainer.scrollLeft = px;
}
// Update time text
this.timeDisplay.innerText = `${Utils.formatTime(this.currentTime)} / ${Utils.formatTime(this.timelineDuration)}`;
}
updatePlayerSource() {
// Find which clip is active at currentTime on the top-most track
let activeClip = null;
let activeTrackIdx = -1;
// Iterate tracks in order (0 is top)
for (let i = 0; i < this.tracks.length; i++) {
const track = this.tracks[i];
// Find a clip that covers currentTime
const clip = track.clips.find(c =>
this.currentTime >= c.startTime &&
this.currentTime < c.startTime + c.duration
);
if (clip) {
activeClip = clip;
activeTrackIdx = i;
break; // Found the top-most clip
}
}
if (activeClip) {
// Only change source if it's different
if (this.videoPlayer.dataset.assetId !== activeClip.assetId) {
const asset = this.assets.find(a => a.id === activeClip.assetId);
if (asset) {
this.videoPlayer.src = asset.url;
this.videoPlayer.dataset.assetId = activeClip.assetId;
}
}
// Sync time
const timeInClip = this.currentTime - activeClip.startTime + activeClip.offset;
// Allow slight tolerance to prevent jitter loops
if (Math.abs(this.videoPlayer.currentTime - timeInClip) > 0.3) {
this.videoPlayer.currentTime = timeInClip;
}
// Ensure playing if global state is playing
if (this.isPlaying && this.videoPlayer.paused) {
this.videoPlayer.play().catch(e => console.log("Buffering..."));
}
} else {
// No clip at this time
if (!this.videoPlayer.paused) {
this.videoPlayer.pause();
}
// Optionally show black or last frame.
// We'll keep the last frame but pause it.
}
}
gameLoop() {
if (this.isPlaying) {
// Increment time based on real delta
const now = performance.now();
const delta = (now - (this.lastFrameTime || now)) / 1000;
this.lastFrameTime = now;
this.currentTime += delta;
if (this.currentTime > this.timelineDuration) {
this.currentTime = this.timelineDuration;
this.togglePlay(); // Stop at end
}
this.updatePlayhead();
this.updatePlayerSource();
} else {
this.lastFrameTime = performance.now();
}
requestAnimationFrame(this.gameLoop.bind(this));
}
/* --- Utilities --- */
zoom(factor) {
this.pixelsPerSecond *= factor;
// Limit zoom
if (this.pixelsPerSecond < 5) this.pixelsPerSecond = 5;
if (this.pixelsPerSecond > 200) this.pixelsPerSecond = 200;
this.renderRuler();
this.renderTracks(); // Re-render clips with new scale
this.updatePlayhead();
}
showToast(msg) {
const toast = document.getElementById('toast');
toast.innerText = msg;
toast.classList.add('visible');
setTimeout(() => {
toast.classList.remove('visible');
}, 3000);
}
exportProject() {
this.showToast("Export started... (Simulation)");
setTimeout(() => {
this.isPlaying = false;
this.playBtn.innerText = "Play";
alert("Project Exported Successfully!\n\nNote: In a real-world app, this would use WebAssembly (FFmpeg.wasm) or a backend server to render the video file. This demo simulates the workflow.");
}, 1000);
}
setupEventListeners() {
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.code === 'Space') {
e.preventDefault();
this.togglePlay();
}
});
// Timeline Container reference
this.timelineContainer = document.querySelector('.timeline-container');
}
}
// Initialize App
const app = new App();
</script>
</body>
</html>