├── .babelrc ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LIB_SPECS.md ├── LICENSE.txt ├── README.md ├── build.js ├── icons ├── elmyra.icns ├── elmyra.ico └── elmyra.png ├── package-lock.json ├── package.json └── src ├── assets ├── checker-active.png ├── checker.png ├── favicon.ico └── index.html ├── javascript ├── application.js ├── index │ ├── download_button.js │ ├── embed_button.js │ ├── processing_state.js │ ├── update_button.js │ ├── version_button.js │ └── visualization.js ├── main.js ├── navigation.js ├── preview.js └── wizard │ ├── animated_cross_section.js │ ├── camera_type.js │ ├── cross_section.js │ ├── id.js │ ├── import.js │ ├── media_length.js │ ├── media_resolution.js │ ├── media_type.js │ ├── modifier_type.js │ ├── orient.js │ ├── style_type.js │ └── wizard.js ├── python ├── __init__.py ├── generate.py ├── import.py ├── lib │ ├── __init__.py │ ├── camera.py │ ├── common.py │ ├── context.py │ ├── export.py │ ├── media.py │ ├── meta.py │ ├── modifier.py │ ├── render.py │ ├── style.py │ ├── update.py │ └── version.py ├── render.py └── update.py ├── rust ├── args.rs ├── build.rs ├── context.rs ├── internal_routes.rs ├── internal_routes │ ├── generate.rs │ ├── import.rs │ ├── preview.rs │ ├── upload.rs │ └── visualizations.rs ├── main.rs ├── meta.rs ├── process.rs ├── public_routes.rs ├── public_routes │ ├── index.rs │ └── visualization.rs ├── renderer.rs └── uuid.rs └── scss ├── base.scss ├── main.scss ├── navigation.scss ├── preview.scss └── wizard.scss /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react" 5 | ], 6 | "plugins": [ 7 | "transform-class-properties" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | /.cache/ 3 | /build/ 4 | /lib/ 5 | /node_modules/ 6 | /src/rust/library.rs 7 | /target/ 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This project uses compatible versioning (https://gitlab.com/staltz/comver). 4 | 5 | As Elmyra internally creates blender files (and lets you export and import them back), each release is tied to a certain Blender version to define a frame of compatibility for usage. For each Elmyra release that upgrades to a new blender version the compatible blender release will be listed in parentheses. 6 | 7 | ## 1.0 (Blender 2.82) 8 | 9 | - Elmyra has been upgraded to work with the new Blender 2.80 series, starting at 2.82 for this release 10 | - The server implementation has been completely rewritten in Rust 11 | - A multitude of command line configuration options have been added 12 | - The frontend build process has been migrated to a more modular, bundling based approach 13 | - All frontend/backend dependencies have been updated to their latest versions 14 | - About half of the frontend component implementations have been refactored and modernized 15 | - The blend4web integration was removed (unclear blender 2.80 support, never made it past experimental stage) 16 | 17 | ## Pre 1.0 (Blender 2.79) 18 | 19 | The initial releases of Elmyra were unversioned, they may be of interest to you because they had server implementations in different languages (python/flask in the beginning, javascript/express after that). Other than for historical reference and consulting prior implementation strategies their history should by now have little relevane though. 20 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | [[package]] 4 | name = "aho-corasick" 5 | version = "0.7.10" 6 | source = "registry+https://github.com/rust-lang/crates.io-index" 7 | checksum = "8716408b8bc624ed7f65d223ddb9ac2d044c0547b6fa4b0d554f3a9540496ada" 8 | dependencies = [ 9 | "memchr", 10 | ] 11 | 12 | [[package]] 13 | name = "ansi_term" 14 | version = "0.12.1" 15 | source = "registry+https://github.com/rust-lang/crates.io-index" 16 | checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" 17 | dependencies = [ 18 | "winapi 0.3.8", 19 | ] 20 | 21 | [[package]] 22 | name = "atty" 23 | version = "0.2.14" 24 | source = "registry+https://github.com/rust-lang/crates.io-index" 25 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 26 | dependencies = [ 27 | "hermit-abi", 28 | "libc", 29 | "winapi 0.3.8", 30 | ] 31 | 32 | [[package]] 33 | name = "autocfg" 34 | version = "1.0.0" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "f8aac770f1885fd7e387acedd76065302551364496e46b3dd00860b2f8359b9d" 37 | 38 | [[package]] 39 | name = "base64" 40 | version = "0.9.3" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "489d6c0ed21b11d038c31b6ceccca973e65d73ba3bd8ecb9a2babf5546164643" 43 | dependencies = [ 44 | "byteorder", 45 | "safemem", 46 | ] 47 | 48 | [[package]] 49 | name = "base64" 50 | version = "0.10.1" 51 | source = "registry+https://github.com/rust-lang/crates.io-index" 52 | checksum = "0b25d992356d2eb0ed82172f5248873db5560c4721f564b13cb5193bda5e668e" 53 | dependencies = [ 54 | "byteorder", 55 | ] 56 | 57 | [[package]] 58 | name = "bitflags" 59 | version = "1.2.1" 60 | source = "registry+https://github.com/rust-lang/crates.io-index" 61 | checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" 62 | 63 | [[package]] 64 | name = "byteorder" 65 | version = "1.3.4" 66 | source = "registry+https://github.com/rust-lang/crates.io-index" 67 | checksum = "08c48aae112d48ed9f069b33538ea9e3e90aa263cfa3d1c24309612b1f7472de" 68 | 69 | [[package]] 70 | name = "c2-chacha" 71 | version = "0.2.3" 72 | source = "registry+https://github.com/rust-lang/crates.io-index" 73 | checksum = "214238caa1bf3a496ec3392968969cab8549f96ff30652c9e56885329315f6bb" 74 | dependencies = [ 75 | "ppv-lite86", 76 | ] 77 | 78 | [[package]] 79 | name = "cc" 80 | version = "1.0.50" 81 | source = "registry+https://github.com/rust-lang/crates.io-index" 82 | checksum = "95e28fa049fda1c330bcf9d723be7663a899c4679724b34c81e9f5a326aab8cd" 83 | 84 | [[package]] 85 | name = "cfg-if" 86 | version = "0.1.10" 87 | source = "registry+https://github.com/rust-lang/crates.io-index" 88 | checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" 89 | 90 | [[package]] 91 | name = "clap" 92 | version = "3.0.0-beta.1" 93 | source = "git+https://github.com/clap-rs/clap/#e4a7f5012855e9dd906caaa7028393cf1926da8e" 94 | dependencies = [ 95 | "ansi_term", 96 | "atty", 97 | "bitflags", 98 | "clap_derive", 99 | "indexmap", 100 | "lazy_static", 101 | "strsim", 102 | "textwrap", 103 | "unicode-width", 104 | "vec_map", 105 | ] 106 | 107 | [[package]] 108 | name = "clap_derive" 109 | version = "3.0.0-beta.1" 110 | source = "git+https://github.com/clap-rs/clap/#e4a7f5012855e9dd906caaa7028393cf1926da8e" 111 | dependencies = [ 112 | "heck", 113 | "proc-macro-error", 114 | "proc-macro2 1.0.8", 115 | "quote 1.0.2", 116 | "syn 1.0.14", 117 | ] 118 | 119 | [[package]] 120 | name = "cookie" 121 | version = "0.11.2" 122 | source = "registry+https://github.com/rust-lang/crates.io-index" 123 | checksum = "d9fac5e7bdefb6160fb181ee0eaa6f96704b625c70e6d61c465cb35750a4ea12" 124 | dependencies = [ 125 | "base64 0.9.3", 126 | "ring", 127 | "time", 128 | "url", 129 | ] 130 | 131 | [[package]] 132 | name = "devise" 133 | version = "0.2.0" 134 | source = "registry+https://github.com/rust-lang/crates.io-index" 135 | checksum = "74e04ba2d03c5fa0d954c061fc8c9c288badadffc272ebb87679a89846de3ed3" 136 | dependencies = [ 137 | "devise_codegen", 138 | "devise_core", 139 | ] 140 | 141 | [[package]] 142 | name = "devise_codegen" 143 | version = "0.2.0" 144 | source = "registry+https://github.com/rust-lang/crates.io-index" 145 | checksum = "066ceb7928ca93a9bedc6d0e612a8a0424048b0ab1f75971b203d01420c055d7" 146 | dependencies = [ 147 | "devise_core", 148 | "quote 0.6.13", 149 | ] 150 | 151 | [[package]] 152 | name = "devise_core" 153 | version = "0.2.0" 154 | source = "registry+https://github.com/rust-lang/crates.io-index" 155 | checksum = "cf41c59b22b5e3ec0ea55c7847e5f358d340f3a8d6d53a5cf4f1564967f96487" 156 | dependencies = [ 157 | "bitflags", 158 | "proc-macro2 0.4.30", 159 | "quote 0.6.13", 160 | "syn 0.15.44", 161 | ] 162 | 163 | [[package]] 164 | name = "elmyra" 165 | version = "1.0.0" 166 | dependencies = [ 167 | "clap", 168 | "env_logger", 169 | "log 0.4.8", 170 | "process_path", 171 | "rocket", 172 | "rocket_contrib", 173 | "serde", 174 | "serde_derive", 175 | "serde_json", 176 | "uuid", 177 | ] 178 | 179 | [[package]] 180 | name = "env_logger" 181 | version = "0.7.1" 182 | source = "registry+https://github.com/rust-lang/crates.io-index" 183 | checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36" 184 | dependencies = [ 185 | "atty", 186 | "humantime", 187 | "log 0.4.8", 188 | "regex", 189 | "termcolor", 190 | ] 191 | 192 | [[package]] 193 | name = "filetime" 194 | version = "0.2.8" 195 | source = "registry+https://github.com/rust-lang/crates.io-index" 196 | checksum = "1ff6d4dab0aa0c8e6346d46052e93b13a16cf847b54ed357087c35011048cc7d" 197 | dependencies = [ 198 | "cfg-if", 199 | "libc", 200 | "redox_syscall", 201 | "winapi 0.3.8", 202 | ] 203 | 204 | [[package]] 205 | name = "fsevent" 206 | version = "0.4.0" 207 | source = "registry+https://github.com/rust-lang/crates.io-index" 208 | checksum = "5ab7d1bd1bd33cc98b0889831b72da23c0aa4df9cec7e0702f46ecea04b35db6" 209 | dependencies = [ 210 | "bitflags", 211 | "fsevent-sys", 212 | ] 213 | 214 | [[package]] 215 | name = "fsevent-sys" 216 | version = "2.0.1" 217 | source = "registry+https://github.com/rust-lang/crates.io-index" 218 | checksum = "f41b048a94555da0f42f1d632e2e19510084fb8e303b0daa2816e733fb3644a0" 219 | dependencies = [ 220 | "libc", 221 | ] 222 | 223 | [[package]] 224 | name = "fuchsia-zircon" 225 | version = "0.3.3" 226 | source = "registry+https://github.com/rust-lang/crates.io-index" 227 | checksum = "2e9763c69ebaae630ba35f74888db465e49e259ba1bc0eda7d06f4a067615d82" 228 | dependencies = [ 229 | "bitflags", 230 | "fuchsia-zircon-sys", 231 | ] 232 | 233 | [[package]] 234 | name = "fuchsia-zircon-sys" 235 | version = "0.3.3" 236 | source = "registry+https://github.com/rust-lang/crates.io-index" 237 | checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" 238 | 239 | [[package]] 240 | name = "getrandom" 241 | version = "0.1.14" 242 | source = "registry+https://github.com/rust-lang/crates.io-index" 243 | checksum = "7abc8dd8451921606d809ba32e95b6111925cd2906060d2dcc29c070220503eb" 244 | dependencies = [ 245 | "cfg-if", 246 | "libc", 247 | "wasi", 248 | ] 249 | 250 | [[package]] 251 | name = "heck" 252 | version = "0.3.1" 253 | source = "registry+https://github.com/rust-lang/crates.io-index" 254 | checksum = "20564e78d53d2bb135c343b3f47714a56af2061f1c928fdb541dc7b9fdd94205" 255 | dependencies = [ 256 | "unicode-segmentation", 257 | ] 258 | 259 | [[package]] 260 | name = "hermit-abi" 261 | version = "0.1.6" 262 | source = "registry+https://github.com/rust-lang/crates.io-index" 263 | checksum = "eff2656d88f158ce120947499e971d743c05dbcbed62e5bd2f38f1698bbc3772" 264 | dependencies = [ 265 | "libc", 266 | ] 267 | 268 | [[package]] 269 | name = "httparse" 270 | version = "1.3.4" 271 | source = "registry+https://github.com/rust-lang/crates.io-index" 272 | checksum = "cd179ae861f0c2e53da70d892f5f3029f9594be0c41dc5269cd371691b1dc2f9" 273 | 274 | [[package]] 275 | name = "humantime" 276 | version = "1.3.0" 277 | source = "registry+https://github.com/rust-lang/crates.io-index" 278 | checksum = "df004cfca50ef23c36850aaaa59ad52cc70d0e90243c3c7737a4dd32dc7a3c4f" 279 | dependencies = [ 280 | "quick-error", 281 | ] 282 | 283 | [[package]] 284 | name = "hyper" 285 | version = "0.10.16" 286 | source = "registry+https://github.com/rust-lang/crates.io-index" 287 | checksum = "0a0652d9a2609a968c14be1a9ea00bf4b1d64e2e1f53a1b51b6fff3a6e829273" 288 | dependencies = [ 289 | "base64 0.9.3", 290 | "httparse", 291 | "language-tags", 292 | "log 0.3.9", 293 | "mime", 294 | "num_cpus", 295 | "time", 296 | "traitobject", 297 | "typeable", 298 | "unicase", 299 | "url", 300 | ] 301 | 302 | [[package]] 303 | name = "idna" 304 | version = "0.1.5" 305 | source = "registry+https://github.com/rust-lang/crates.io-index" 306 | checksum = "38f09e0f0b1fb55fdee1f17470ad800da77af5186a1a76c026b679358b7e844e" 307 | dependencies = [ 308 | "matches", 309 | "unicode-bidi", 310 | "unicode-normalization", 311 | ] 312 | 313 | [[package]] 314 | name = "indexmap" 315 | version = "1.3.2" 316 | source = "registry+https://github.com/rust-lang/crates.io-index" 317 | checksum = "076f042c5b7b98f31d205f1249267e12a6518c1481e9dae9764af19b707d2292" 318 | dependencies = [ 319 | "autocfg", 320 | ] 321 | 322 | [[package]] 323 | name = "inotify" 324 | version = "0.7.0" 325 | source = "registry+https://github.com/rust-lang/crates.io-index" 326 | checksum = "24e40d6fd5d64e2082e0c796495c8ef5ad667a96d03e5aaa0becfd9d47bcbfb8" 327 | dependencies = [ 328 | "bitflags", 329 | "inotify-sys", 330 | "libc", 331 | ] 332 | 333 | [[package]] 334 | name = "inotify-sys" 335 | version = "0.1.3" 336 | source = "registry+https://github.com/rust-lang/crates.io-index" 337 | checksum = "e74a1aa87c59aeff6ef2cc2fa62d41bc43f54952f55652656b18a02fd5e356c0" 338 | dependencies = [ 339 | "libc", 340 | ] 341 | 342 | [[package]] 343 | name = "iovec" 344 | version = "0.1.4" 345 | source = "registry+https://github.com/rust-lang/crates.io-index" 346 | checksum = "b2b3ea6ff95e175473f8ffe6a7eb7c00d054240321b84c57051175fe3c1e075e" 347 | dependencies = [ 348 | "libc", 349 | ] 350 | 351 | [[package]] 352 | name = "itoa" 353 | version = "0.4.5" 354 | source = "registry+https://github.com/rust-lang/crates.io-index" 355 | checksum = "b8b7a7c0c47db5545ed3fef7468ee7bb5b74691498139e4b3f6a20685dc6dd8e" 356 | 357 | [[package]] 358 | name = "kernel32-sys" 359 | version = "0.2.2" 360 | source = "registry+https://github.com/rust-lang/crates.io-index" 361 | checksum = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d" 362 | dependencies = [ 363 | "winapi 0.2.8", 364 | "winapi-build", 365 | ] 366 | 367 | [[package]] 368 | name = "language-tags" 369 | version = "0.2.2" 370 | source = "registry+https://github.com/rust-lang/crates.io-index" 371 | checksum = "a91d884b6667cd606bb5a69aa0c99ba811a115fc68915e7056ec08a46e93199a" 372 | 373 | [[package]] 374 | name = "lazy_static" 375 | version = "1.4.0" 376 | source = "registry+https://github.com/rust-lang/crates.io-index" 377 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 378 | 379 | [[package]] 380 | name = "lazycell" 381 | version = "1.2.1" 382 | source = "registry+https://github.com/rust-lang/crates.io-index" 383 | checksum = "b294d6fa9ee409a054354afc4352b0b9ef7ca222c69b8812cbea9e7d2bf3783f" 384 | 385 | [[package]] 386 | name = "libc" 387 | version = "0.2.66" 388 | source = "registry+https://github.com/rust-lang/crates.io-index" 389 | checksum = "d515b1f41455adea1313a4a2ac8a8a477634fbae63cc6100e3aebb207ce61558" 390 | 391 | [[package]] 392 | name = "log" 393 | version = "0.3.9" 394 | source = "registry+https://github.com/rust-lang/crates.io-index" 395 | checksum = "e19e8d5c34a3e0e2223db8e060f9e8264aeeb5c5fc64a4ee9965c062211c024b" 396 | dependencies = [ 397 | "log 0.4.8", 398 | ] 399 | 400 | [[package]] 401 | name = "log" 402 | version = "0.4.8" 403 | source = "registry+https://github.com/rust-lang/crates.io-index" 404 | checksum = "14b6052be84e6b71ab17edffc2eeabf5c2c3ae1fdb464aae35ac50c67a44e1f7" 405 | dependencies = [ 406 | "cfg-if", 407 | ] 408 | 409 | [[package]] 410 | name = "matches" 411 | version = "0.1.8" 412 | source = "registry+https://github.com/rust-lang/crates.io-index" 413 | checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" 414 | 415 | [[package]] 416 | name = "maybe-uninit" 417 | version = "2.0.0" 418 | source = "registry+https://github.com/rust-lang/crates.io-index" 419 | checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00" 420 | 421 | [[package]] 422 | name = "memchr" 423 | version = "2.3.2" 424 | source = "registry+https://github.com/rust-lang/crates.io-index" 425 | checksum = "53445de381a1f436797497c61d851644d0e8e88e6140f22872ad33a704933978" 426 | 427 | [[package]] 428 | name = "mime" 429 | version = "0.2.6" 430 | source = "registry+https://github.com/rust-lang/crates.io-index" 431 | checksum = "ba626b8a6de5da682e1caa06bdb42a335aee5a84db8e5046a3e8ab17ba0a3ae0" 432 | dependencies = [ 433 | "log 0.3.9", 434 | ] 435 | 436 | [[package]] 437 | name = "mio" 438 | version = "0.6.21" 439 | source = "registry+https://github.com/rust-lang/crates.io-index" 440 | checksum = "302dec22bcf6bae6dfb69c647187f4b4d0fb6f535521f7bc022430ce8e12008f" 441 | dependencies = [ 442 | "cfg-if", 443 | "fuchsia-zircon", 444 | "fuchsia-zircon-sys", 445 | "iovec", 446 | "kernel32-sys", 447 | "libc", 448 | "log 0.4.8", 449 | "miow", 450 | "net2", 451 | "slab", 452 | "winapi 0.2.8", 453 | ] 454 | 455 | [[package]] 456 | name = "mio-extras" 457 | version = "2.0.6" 458 | source = "registry+https://github.com/rust-lang/crates.io-index" 459 | checksum = "52403fe290012ce777c4626790c8951324a2b9e3316b3143779c72b029742f19" 460 | dependencies = [ 461 | "lazycell", 462 | "log 0.4.8", 463 | "mio", 464 | "slab", 465 | ] 466 | 467 | [[package]] 468 | name = "miow" 469 | version = "0.2.1" 470 | source = "registry+https://github.com/rust-lang/crates.io-index" 471 | checksum = "8c1f2f3b1cf331de6896aabf6e9d55dca90356cc9960cca7eaaf408a355ae919" 472 | dependencies = [ 473 | "kernel32-sys", 474 | "net2", 475 | "winapi 0.2.8", 476 | "ws2_32-sys", 477 | ] 478 | 479 | [[package]] 480 | name = "net2" 481 | version = "0.2.33" 482 | source = "registry+https://github.com/rust-lang/crates.io-index" 483 | checksum = "42550d9fb7b6684a6d404d9fa7250c2eb2646df731d1c06afc06dcee9e1bcf88" 484 | dependencies = [ 485 | "cfg-if", 486 | "libc", 487 | "winapi 0.3.8", 488 | ] 489 | 490 | [[package]] 491 | name = "notify" 492 | version = "4.0.15" 493 | source = "registry+https://github.com/rust-lang/crates.io-index" 494 | checksum = "80ae4a7688d1fab81c5bf19c64fc8db920be8d519ce6336ed4e7efe024724dbd" 495 | dependencies = [ 496 | "bitflags", 497 | "filetime", 498 | "fsevent", 499 | "fsevent-sys", 500 | "inotify", 501 | "libc", 502 | "mio", 503 | "mio-extras", 504 | "walkdir", 505 | "winapi 0.3.8", 506 | ] 507 | 508 | [[package]] 509 | name = "num_cpus" 510 | version = "1.12.0" 511 | source = "registry+https://github.com/rust-lang/crates.io-index" 512 | checksum = "46203554f085ff89c235cd12f7075f3233af9b11ed7c9e16dfe2560d03313ce6" 513 | dependencies = [ 514 | "hermit-abi", 515 | "libc", 516 | ] 517 | 518 | [[package]] 519 | name = "pear" 520 | version = "0.1.2" 521 | source = "registry+https://github.com/rust-lang/crates.io-index" 522 | checksum = "c26d2b92e47063ffce70d3e3b1bd097af121a9e0db07ca38a6cc1cf0cc85ff25" 523 | dependencies = [ 524 | "pear_codegen", 525 | ] 526 | 527 | [[package]] 528 | name = "pear_codegen" 529 | version = "0.1.2" 530 | source = "registry+https://github.com/rust-lang/crates.io-index" 531 | checksum = "336db4a192cc7f54efeb0c4e11a9245394824cc3bcbd37ba3ff51240c35d7a6e" 532 | dependencies = [ 533 | "proc-macro2 0.4.30", 534 | "quote 0.6.13", 535 | "syn 0.15.44", 536 | "version_check 0.1.5", 537 | "yansi 0.4.0", 538 | ] 539 | 540 | [[package]] 541 | name = "percent-encoding" 542 | version = "1.0.1" 543 | source = "registry+https://github.com/rust-lang/crates.io-index" 544 | checksum = "31010dd2e1ac33d5b46a5b413495239882813e0369f8ed8a5e266f173602f831" 545 | 546 | [[package]] 547 | name = "ppv-lite86" 548 | version = "0.2.6" 549 | source = "registry+https://github.com/rust-lang/crates.io-index" 550 | checksum = "74490b50b9fbe561ac330df47c08f3f33073d2d00c150f719147d7c54522fa1b" 551 | 552 | [[package]] 553 | name = "proc-macro-error" 554 | version = "0.4.9" 555 | source = "registry+https://github.com/rust-lang/crates.io-index" 556 | checksum = "052b3c9af39c7e5e94245f820530487d19eb285faedcb40e0c3275132293f242" 557 | dependencies = [ 558 | "proc-macro-error-attr", 559 | "proc-macro2 1.0.8", 560 | "quote 1.0.2", 561 | "rustversion", 562 | "syn 1.0.14", 563 | ] 564 | 565 | [[package]] 566 | name = "proc-macro-error-attr" 567 | version = "0.4.9" 568 | source = "registry+https://github.com/rust-lang/crates.io-index" 569 | checksum = "d175bef481c7902e63e3165627123fff3502f06ac043d3ef42d08c1246da9253" 570 | dependencies = [ 571 | "proc-macro2 1.0.8", 572 | "quote 1.0.2", 573 | "rustversion", 574 | "syn 1.0.14", 575 | "syn-mid", 576 | ] 577 | 578 | [[package]] 579 | name = "proc-macro2" 580 | version = "0.4.30" 581 | source = "registry+https://github.com/rust-lang/crates.io-index" 582 | checksum = "cf3d2011ab5c909338f7887f4fc896d35932e29146c12c8d01da6b22a80ba759" 583 | dependencies = [ 584 | "unicode-xid 0.1.0", 585 | ] 586 | 587 | [[package]] 588 | name = "proc-macro2" 589 | version = "1.0.8" 590 | source = "registry+https://github.com/rust-lang/crates.io-index" 591 | checksum = "3acb317c6ff86a4e579dfa00fc5e6cca91ecbb4e7eb2df0468805b674eb88548" 592 | dependencies = [ 593 | "unicode-xid 0.2.0", 594 | ] 595 | 596 | [[package]] 597 | name = "process_path" 598 | version = "0.1.1" 599 | source = "registry+https://github.com/rust-lang/crates.io-index" 600 | checksum = "f9bdceb3b6b44ee28251e5ac737472de60c9e706e8f5483730d4f6f61d3a0526" 601 | dependencies = [ 602 | "kernel32-sys", 603 | "libc", 604 | "winapi 0.2.8", 605 | ] 606 | 607 | [[package]] 608 | name = "quick-error" 609 | version = "1.2.3" 610 | source = "registry+https://github.com/rust-lang/crates.io-index" 611 | checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" 612 | 613 | [[package]] 614 | name = "quote" 615 | version = "0.6.13" 616 | source = "registry+https://github.com/rust-lang/crates.io-index" 617 | checksum = "6ce23b6b870e8f94f81fb0a363d65d86675884b34a09043c81e5562f11c1f8e1" 618 | dependencies = [ 619 | "proc-macro2 0.4.30", 620 | ] 621 | 622 | [[package]] 623 | name = "quote" 624 | version = "1.0.2" 625 | source = "registry+https://github.com/rust-lang/crates.io-index" 626 | checksum = "053a8c8bcc71fcce321828dc897a98ab9760bef03a4fc36693c231e5b3216cfe" 627 | dependencies = [ 628 | "proc-macro2 1.0.8", 629 | ] 630 | 631 | [[package]] 632 | name = "rand" 633 | version = "0.7.3" 634 | source = "registry+https://github.com/rust-lang/crates.io-index" 635 | checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" 636 | dependencies = [ 637 | "getrandom", 638 | "libc", 639 | "rand_chacha", 640 | "rand_core", 641 | "rand_hc", 642 | ] 643 | 644 | [[package]] 645 | name = "rand_chacha" 646 | version = "0.2.1" 647 | source = "registry+https://github.com/rust-lang/crates.io-index" 648 | checksum = "03a2a90da8c7523f554344f921aa97283eadf6ac484a6d2a7d0212fa7f8d6853" 649 | dependencies = [ 650 | "c2-chacha", 651 | "rand_core", 652 | ] 653 | 654 | [[package]] 655 | name = "rand_core" 656 | version = "0.5.1" 657 | source = "registry+https://github.com/rust-lang/crates.io-index" 658 | checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" 659 | dependencies = [ 660 | "getrandom", 661 | ] 662 | 663 | [[package]] 664 | name = "rand_hc" 665 | version = "0.2.0" 666 | source = "registry+https://github.com/rust-lang/crates.io-index" 667 | checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" 668 | dependencies = [ 669 | "rand_core", 670 | ] 671 | 672 | [[package]] 673 | name = "redox_syscall" 674 | version = "0.1.56" 675 | source = "registry+https://github.com/rust-lang/crates.io-index" 676 | checksum = "2439c63f3f6139d1b57529d16bc3b8bb855230c8efcc5d3a896c8bea7c3b1e84" 677 | 678 | [[package]] 679 | name = "regex" 680 | version = "1.3.6" 681 | source = "registry+https://github.com/rust-lang/crates.io-index" 682 | checksum = "7f6946991529684867e47d86474e3a6d0c0ab9b82d5821e314b1ede31fa3a4b3" 683 | dependencies = [ 684 | "aho-corasick", 685 | "memchr", 686 | "regex-syntax", 687 | "thread_local", 688 | ] 689 | 690 | [[package]] 691 | name = "regex-syntax" 692 | version = "0.6.17" 693 | source = "registry+https://github.com/rust-lang/crates.io-index" 694 | checksum = "7fe5bd57d1d7414c6b5ed48563a2c855d995ff777729dcd91c369ec7fea395ae" 695 | 696 | [[package]] 697 | name = "ring" 698 | version = "0.13.5" 699 | source = "registry+https://github.com/rust-lang/crates.io-index" 700 | checksum = "2c4db68a2e35f3497146b7e4563df7d4773a2433230c5e4b448328e31740458a" 701 | dependencies = [ 702 | "cc", 703 | "lazy_static", 704 | "libc", 705 | "untrusted", 706 | ] 707 | 708 | [[package]] 709 | name = "rocket" 710 | version = "0.4.2" 711 | source = "registry+https://github.com/rust-lang/crates.io-index" 712 | checksum = "42c1e9deb3ef4fa430d307bfccd4231434b707ca1328fae339c43ad1201cc6f7" 713 | dependencies = [ 714 | "atty", 715 | "base64 0.10.1", 716 | "log 0.4.8", 717 | "memchr", 718 | "num_cpus", 719 | "pear", 720 | "rocket_codegen", 721 | "rocket_http", 722 | "state", 723 | "time", 724 | "toml", 725 | "version_check 0.9.1", 726 | "yansi 0.5.0", 727 | ] 728 | 729 | [[package]] 730 | name = "rocket_codegen" 731 | version = "0.4.2" 732 | source = "registry+https://github.com/rust-lang/crates.io-index" 733 | checksum = "79aa1366f9b2eccddc05971e17c5de7bb75a5431eb12c2b5c66545fd348647f4" 734 | dependencies = [ 735 | "devise", 736 | "indexmap", 737 | "quote 0.6.13", 738 | "rocket_http", 739 | "version_check 0.9.1", 740 | "yansi 0.5.0", 741 | ] 742 | 743 | [[package]] 744 | name = "rocket_contrib" 745 | version = "0.4.2" 746 | source = "registry+https://github.com/rust-lang/crates.io-index" 747 | checksum = "e0fa5c1392135adc0f96a02ba150ac4c765e27c58dbfd32aa40678e948f6e56f" 748 | dependencies = [ 749 | "log 0.4.8", 750 | "notify", 751 | "rocket", 752 | "serde", 753 | "serde_json", 754 | ] 755 | 756 | [[package]] 757 | name = "rocket_http" 758 | version = "0.4.2" 759 | source = "registry+https://github.com/rust-lang/crates.io-index" 760 | checksum = "b1391457ee4e80b40d4b57fa5765c0f2836b20d73bcbee4e3f35d93cf3b80817" 761 | dependencies = [ 762 | "cookie", 763 | "hyper", 764 | "indexmap", 765 | "pear", 766 | "percent-encoding", 767 | "smallvec 0.6.13", 768 | "state", 769 | "time", 770 | "unicode-xid 0.1.0", 771 | ] 772 | 773 | [[package]] 774 | name = "rustversion" 775 | version = "1.0.2" 776 | source = "registry+https://github.com/rust-lang/crates.io-index" 777 | checksum = "b3bba175698996010c4f6dce5e7f173b6eb781fce25d2cfc45e27091ce0b79f6" 778 | dependencies = [ 779 | "proc-macro2 1.0.8", 780 | "quote 1.0.2", 781 | "syn 1.0.14", 782 | ] 783 | 784 | [[package]] 785 | name = "ryu" 786 | version = "1.0.2" 787 | source = "registry+https://github.com/rust-lang/crates.io-index" 788 | checksum = "bfa8506c1de11c9c4e4c38863ccbe02a305c8188e85a05a784c9e11e1c3910c8" 789 | 790 | [[package]] 791 | name = "safemem" 792 | version = "0.3.3" 793 | source = "registry+https://github.com/rust-lang/crates.io-index" 794 | checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" 795 | 796 | [[package]] 797 | name = "same-file" 798 | version = "1.0.6" 799 | source = "registry+https://github.com/rust-lang/crates.io-index" 800 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 801 | dependencies = [ 802 | "winapi-util", 803 | ] 804 | 805 | [[package]] 806 | name = "serde" 807 | version = "1.0.104" 808 | source = "registry+https://github.com/rust-lang/crates.io-index" 809 | checksum = "414115f25f818d7dfccec8ee535d76949ae78584fc4f79a6f45a904bf8ab4449" 810 | 811 | [[package]] 812 | name = "serde_derive" 813 | version = "1.0.104" 814 | source = "registry+https://github.com/rust-lang/crates.io-index" 815 | checksum = "128f9e303a5a29922045a830221b8f78ec74a5f544944f3d5984f8ec3895ef64" 816 | dependencies = [ 817 | "proc-macro2 1.0.8", 818 | "quote 1.0.2", 819 | "syn 1.0.14", 820 | ] 821 | 822 | [[package]] 823 | name = "serde_json" 824 | version = "1.0.48" 825 | source = "registry+https://github.com/rust-lang/crates.io-index" 826 | checksum = "9371ade75d4c2d6cb154141b9752cf3781ec9c05e0e5cf35060e1e70ee7b9c25" 827 | dependencies = [ 828 | "itoa", 829 | "ryu", 830 | "serde", 831 | ] 832 | 833 | [[package]] 834 | name = "slab" 835 | version = "0.4.2" 836 | source = "registry+https://github.com/rust-lang/crates.io-index" 837 | checksum = "c111b5bd5695e56cffe5129854aa230b39c93a305372fdbb2668ca2394eea9f8" 838 | 839 | [[package]] 840 | name = "smallvec" 841 | version = "0.6.13" 842 | source = "registry+https://github.com/rust-lang/crates.io-index" 843 | checksum = "f7b0758c52e15a8b5e3691eae6cc559f08eee9406e548a4477ba4e67770a82b6" 844 | dependencies = [ 845 | "maybe-uninit", 846 | ] 847 | 848 | [[package]] 849 | name = "smallvec" 850 | version = "1.2.0" 851 | source = "registry+https://github.com/rust-lang/crates.io-index" 852 | checksum = "5c2fb2ec9bcd216a5b0d0ccf31ab17b5ed1d627960edff65bbe95d3ce221cefc" 853 | 854 | [[package]] 855 | name = "state" 856 | version = "0.4.1" 857 | source = "registry+https://github.com/rust-lang/crates.io-index" 858 | checksum = "7345c971d1ef21ffdbd103a75990a15eb03604fc8b8852ca8cb418ee1a099028" 859 | 860 | [[package]] 861 | name = "strsim" 862 | version = "0.9.3" 863 | source = "registry+https://github.com/rust-lang/crates.io-index" 864 | checksum = "6446ced80d6c486436db5c078dde11a9f73d42b57fb273121e160b84f63d894c" 865 | 866 | [[package]] 867 | name = "syn" 868 | version = "0.15.44" 869 | source = "registry+https://github.com/rust-lang/crates.io-index" 870 | checksum = "9ca4b3b69a77cbe1ffc9e198781b7acb0c7365a883670e8f1c1bc66fba79a5c5" 871 | dependencies = [ 872 | "proc-macro2 0.4.30", 873 | "quote 0.6.13", 874 | "unicode-xid 0.1.0", 875 | ] 876 | 877 | [[package]] 878 | name = "syn" 879 | version = "1.0.14" 880 | source = "registry+https://github.com/rust-lang/crates.io-index" 881 | checksum = "af6f3550d8dff9ef7dc34d384ac6f107e5d31c8f57d9f28e0081503f547ac8f5" 882 | dependencies = [ 883 | "proc-macro2 1.0.8", 884 | "quote 1.0.2", 885 | "unicode-xid 0.2.0", 886 | ] 887 | 888 | [[package]] 889 | name = "syn-mid" 890 | version = "0.5.0" 891 | source = "registry+https://github.com/rust-lang/crates.io-index" 892 | checksum = "7be3539f6c128a931cf19dcee741c1af532c7fd387baa739c03dd2e96479338a" 893 | dependencies = [ 894 | "proc-macro2 1.0.8", 895 | "quote 1.0.2", 896 | "syn 1.0.14", 897 | ] 898 | 899 | [[package]] 900 | name = "termcolor" 901 | version = "1.1.0" 902 | source = "registry+https://github.com/rust-lang/crates.io-index" 903 | checksum = "bb6bfa289a4d7c5766392812c0a1f4c1ba45afa1ad47803c11e1f407d846d75f" 904 | dependencies = [ 905 | "winapi-util", 906 | ] 907 | 908 | [[package]] 909 | name = "textwrap" 910 | version = "0.11.0" 911 | source = "registry+https://github.com/rust-lang/crates.io-index" 912 | checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" 913 | dependencies = [ 914 | "unicode-width", 915 | ] 916 | 917 | [[package]] 918 | name = "thread_local" 919 | version = "1.0.1" 920 | source = "registry+https://github.com/rust-lang/crates.io-index" 921 | checksum = "d40c6d1b69745a6ec6fb1ca717914848da4b44ae29d9b3080cbee91d72a69b14" 922 | dependencies = [ 923 | "lazy_static", 924 | ] 925 | 926 | [[package]] 927 | name = "time" 928 | version = "0.1.42" 929 | source = "registry+https://github.com/rust-lang/crates.io-index" 930 | checksum = "db8dcfca086c1143c9270ac42a2bbd8a7ee477b78ac8e45b19abfb0cbede4b6f" 931 | dependencies = [ 932 | "libc", 933 | "redox_syscall", 934 | "winapi 0.3.8", 935 | ] 936 | 937 | [[package]] 938 | name = "toml" 939 | version = "0.4.10" 940 | source = "registry+https://github.com/rust-lang/crates.io-index" 941 | checksum = "758664fc71a3a69038656bee8b6be6477d2a6c315a6b81f7081f591bffa4111f" 942 | dependencies = [ 943 | "serde", 944 | ] 945 | 946 | [[package]] 947 | name = "traitobject" 948 | version = "0.1.0" 949 | source = "registry+https://github.com/rust-lang/crates.io-index" 950 | checksum = "efd1f82c56340fdf16f2a953d7bda4f8fdffba13d93b00844c25572110b26079" 951 | 952 | [[package]] 953 | name = "typeable" 954 | version = "0.1.2" 955 | source = "registry+https://github.com/rust-lang/crates.io-index" 956 | checksum = "1410f6f91f21d1612654e7cc69193b0334f909dcf2c790c4826254fbb86f8887" 957 | 958 | [[package]] 959 | name = "unicase" 960 | version = "1.4.2" 961 | source = "registry+https://github.com/rust-lang/crates.io-index" 962 | checksum = "7f4765f83163b74f957c797ad9253caf97f103fb064d3999aea9568d09fc8a33" 963 | dependencies = [ 964 | "version_check 0.1.5", 965 | ] 966 | 967 | [[package]] 968 | name = "unicode-bidi" 969 | version = "0.3.4" 970 | source = "registry+https://github.com/rust-lang/crates.io-index" 971 | checksum = "49f2bd0c6468a8230e1db229cff8029217cf623c767ea5d60bfbd42729ea54d5" 972 | dependencies = [ 973 | "matches", 974 | ] 975 | 976 | [[package]] 977 | name = "unicode-normalization" 978 | version = "0.1.12" 979 | source = "registry+https://github.com/rust-lang/crates.io-index" 980 | checksum = "5479532badd04e128284890390c1e876ef7a993d0570b3597ae43dfa1d59afa4" 981 | dependencies = [ 982 | "smallvec 1.2.0", 983 | ] 984 | 985 | [[package]] 986 | name = "unicode-segmentation" 987 | version = "1.6.0" 988 | source = "registry+https://github.com/rust-lang/crates.io-index" 989 | checksum = "e83e153d1053cbb5a118eeff7fd5be06ed99153f00dbcd8ae310c5fb2b22edc0" 990 | 991 | [[package]] 992 | name = "unicode-width" 993 | version = "0.1.7" 994 | source = "registry+https://github.com/rust-lang/crates.io-index" 995 | checksum = "caaa9d531767d1ff2150b9332433f32a24622147e5ebb1f26409d5da67afd479" 996 | 997 | [[package]] 998 | name = "unicode-xid" 999 | version = "0.1.0" 1000 | source = "registry+https://github.com/rust-lang/crates.io-index" 1001 | checksum = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc" 1002 | 1003 | [[package]] 1004 | name = "unicode-xid" 1005 | version = "0.2.0" 1006 | source = "registry+https://github.com/rust-lang/crates.io-index" 1007 | checksum = "826e7639553986605ec5979c7dd957c7895e93eabed50ab2ffa7f6128a75097c" 1008 | 1009 | [[package]] 1010 | name = "untrusted" 1011 | version = "0.6.2" 1012 | source = "registry+https://github.com/rust-lang/crates.io-index" 1013 | checksum = "55cd1f4b4e96b46aeb8d4855db4a7a9bd96eeeb5c6a1ab54593328761642ce2f" 1014 | 1015 | [[package]] 1016 | name = "url" 1017 | version = "1.7.2" 1018 | source = "registry+https://github.com/rust-lang/crates.io-index" 1019 | checksum = "dd4e7c0d531266369519a4aa4f399d748bd37043b00bde1e4ff1f60a120b355a" 1020 | dependencies = [ 1021 | "idna", 1022 | "matches", 1023 | "percent-encoding", 1024 | ] 1025 | 1026 | [[package]] 1027 | name = "uuid" 1028 | version = "0.8.1" 1029 | source = "registry+https://github.com/rust-lang/crates.io-index" 1030 | checksum = "9fde2f6a4bea1d6e007c4ad38c6839fa71cbb63b6dbf5b595aa38dc9b1093c11" 1031 | dependencies = [ 1032 | "rand", 1033 | ] 1034 | 1035 | [[package]] 1036 | name = "vec_map" 1037 | version = "0.8.1" 1038 | source = "registry+https://github.com/rust-lang/crates.io-index" 1039 | checksum = "05c78687fb1a80548ae3250346c3db86a80a7cdd77bda190189f2d0a0987c81a" 1040 | 1041 | [[package]] 1042 | name = "version_check" 1043 | version = "0.1.5" 1044 | source = "registry+https://github.com/rust-lang/crates.io-index" 1045 | checksum = "914b1a6776c4c929a602fafd8bc742e06365d4bcbe48c30f9cca5824f70dc9dd" 1046 | 1047 | [[package]] 1048 | name = "version_check" 1049 | version = "0.9.1" 1050 | source = "registry+https://github.com/rust-lang/crates.io-index" 1051 | checksum = "078775d0255232fb988e6fccf26ddc9d1ac274299aaedcedce21c6f72cc533ce" 1052 | 1053 | [[package]] 1054 | name = "walkdir" 1055 | version = "2.3.1" 1056 | source = "registry+https://github.com/rust-lang/crates.io-index" 1057 | checksum = "777182bc735b6424e1a57516d35ed72cb8019d85c8c9bf536dccb3445c1a2f7d" 1058 | dependencies = [ 1059 | "same-file", 1060 | "winapi 0.3.8", 1061 | "winapi-util", 1062 | ] 1063 | 1064 | [[package]] 1065 | name = "wasi" 1066 | version = "0.9.0+wasi-snapshot-preview1" 1067 | source = "registry+https://github.com/rust-lang/crates.io-index" 1068 | checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" 1069 | 1070 | [[package]] 1071 | name = "winapi" 1072 | version = "0.2.8" 1073 | source = "registry+https://github.com/rust-lang/crates.io-index" 1074 | checksum = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" 1075 | 1076 | [[package]] 1077 | name = "winapi" 1078 | version = "0.3.8" 1079 | source = "registry+https://github.com/rust-lang/crates.io-index" 1080 | checksum = "8093091eeb260906a183e6ae1abdba2ef5ef2257a21801128899c3fc699229c6" 1081 | dependencies = [ 1082 | "winapi-i686-pc-windows-gnu", 1083 | "winapi-x86_64-pc-windows-gnu", 1084 | ] 1085 | 1086 | [[package]] 1087 | name = "winapi-build" 1088 | version = "0.1.1" 1089 | source = "registry+https://github.com/rust-lang/crates.io-index" 1090 | checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" 1091 | 1092 | [[package]] 1093 | name = "winapi-i686-pc-windows-gnu" 1094 | version = "0.4.0" 1095 | source = "registry+https://github.com/rust-lang/crates.io-index" 1096 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1097 | 1098 | [[package]] 1099 | name = "winapi-util" 1100 | version = "0.1.3" 1101 | source = "registry+https://github.com/rust-lang/crates.io-index" 1102 | checksum = "4ccfbf554c6ad11084fb7517daca16cfdcaccbdadba4fc336f032a8b12c2ad80" 1103 | dependencies = [ 1104 | "winapi 0.3.8", 1105 | ] 1106 | 1107 | [[package]] 1108 | name = "winapi-x86_64-pc-windows-gnu" 1109 | version = "0.4.0" 1110 | source = "registry+https://github.com/rust-lang/crates.io-index" 1111 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1112 | 1113 | [[package]] 1114 | name = "ws2_32-sys" 1115 | version = "0.2.1" 1116 | source = "registry+https://github.com/rust-lang/crates.io-index" 1117 | checksum = "d59cefebd0c892fa2dd6de581e937301d8552cb44489cdff035c6187cb63fa5e" 1118 | dependencies = [ 1119 | "winapi 0.2.8", 1120 | "winapi-build", 1121 | ] 1122 | 1123 | [[package]] 1124 | name = "yansi" 1125 | version = "0.4.0" 1126 | source = "registry+https://github.com/rust-lang/crates.io-index" 1127 | checksum = "d60c3b48c9cdec42fb06b3b84b5b087405e1fa1c644a1af3930e4dfafe93de48" 1128 | 1129 | [[package]] 1130 | name = "yansi" 1131 | version = "0.5.0" 1132 | source = "registry+https://github.com/rust-lang/crates.io-index" 1133 | checksum = "9fc79f4a1e39857fc00c3f662cbf2651c771f00e9c15fe2abc341806bd46bd71" 1134 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Simon Repp "] 3 | build = "src/rust/build.rs" 4 | edition = "2018" 5 | license = "GPL-3.0-or-later" 6 | name = "elmyra" 7 | publish = false 8 | version = "1.0.0" 9 | 10 | [build-dependencies] 11 | serde = "1.0.104" 12 | serde_derive = "1.0.104" 13 | serde_json = "1.0.48" 14 | 15 | [dependencies] 16 | clap = { git = "https://github.com/clap-rs/clap/" } 17 | env_logger = "0.7.1" 18 | log = "0.4.8" 19 | process_path = "0.1.1" 20 | rocket = "0.4.2" 21 | rocket_contrib = "0.4.2" 22 | serde = "1.0.104" 23 | serde_derive = "1.0.104" 24 | serde_json = "1.0.48" 25 | uuid = { features = ["v4"], version = "0.8.1" } 26 | 27 | [[bin]] 28 | name = "elmyra" 29 | path = "src/rust/main.rs" 30 | -------------------------------------------------------------------------------- /LIB_SPECS.md: -------------------------------------------------------------------------------- 1 | # Specifications for bundled dependencies in `lib/` 2 | 3 | The officially maintained library bundle is available at https://files.apertus.org/elmyra/elmyra-lib.zip 4 | 5 | ## Platform dependencies 6 | 7 | **The paths to the dependencies' executables need to be updated in the `src/[platform]/paths.json` manifests for every update!** 8 | 9 | ### Blender 2.82 10 | 11 | Blender can be bundled as-is except for one critical manual tweak, namely isolating its runtime environment from any user/system blender that might be installed on the machine where elmyra runs. 12 | For this simply create an empty `config/` folder in blender's `2.82/` directory. (see [Q/A on Blender Stackexchange](https://blender.stackexchange.com/questions/48392/make-blender-unaware-of-user-system-installed-add-ons)) 13 | 14 | ### FFmpeg 4.2.1+ or recent snapshot build from git 15 | 16 | This refers to the executables (`ffmpeg`, `ffplay`, etc. though only `ffmpeg` itself is needed) and not to the `libav*` C libraries. 17 | 18 | ## Asset dependencies 19 | 20 | These are the blender files and image/font file assets Elmyra imports when it generates or updates visualizations. They are located inside `lib/elmyra/` and documented here for overview and development reference purposes. 21 | 22 | - `environment.hdr` 23 | - `illustrated.blend` 24 | - `oxygen-mono.ttf` 25 | - `preview-widgets.blend` 26 | - `realistic.blend` 27 | - `section.blend` 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Elmyra 2 | 3 | An experimental blender-based rapid iterative visualization system. 4 | 5 | [![Conference presentation video thumbnail](http://files.apertus.org/elmyra/screencast-youtube-screenshot.png?)](https://www.youtube.com/watch?v=l8x8Kz1T1uc "Elmyra Screencast") 6 | 7 | ## Download for your OS 8 | 9 | - [Linux (x64)](http://files.apertus.org/elmyra/elmyra-1.0-linux-x64.zip) 10 | - [macOS (x64)](http://files.apertus.org/elmyra/elmyra-1.0-macos-x64.zip) 11 | - [Windows (x64)](http://files.apertus.org/elmyra/elmyra-1.0-windows-x64.zip) 12 | 13 | After extracting the `.zip` package, `cd` into the folder and run the executable with `./elmyra`. By default the server listens on all addresses and port `8080` and thus should be reachable from your browser at `localhost:8080`. Run `./elmyra --help` to see all configuration options. Note that you *don't* need to install blender or anything at all, everything comes bundled with the release package and should work out of the box. Blender data, image and video files, temporary runtime data, etc. are by default stored in folders next to the executable - this can be configured as well. 14 | 15 | ## What is Elmyra? 16 | 17 | - **Visualization Wizard** - Create animated or static visualizations from primitive 3D input files in the browser 18 | - **Automated Rendering** - Get a draft immediately and higher-quality versions delivered continuously over time 19 | - **Continuous Delivery** - Share/embed links to images/videos that always deliver up-to-date material 20 | - **Blender Inside** - At its core Elmyra uses [Blender](https://www.blender.org/), all visualizations can be downloaded as blender files 21 | - **Free & Open Source** - Developed as a part of the [AXIOM Gamma](http://apertus.org/axiom-gamma) project 22 | 23 | For a quick introduction watch the [Elmyra Screencast](https://www.youtube.com/watch?v=l8x8Kz1T1uc), also linked above. (8min) 24 | For more project history and background check out the [Blender Conference 2015 Presentation](https://youtu.be/ht1hPNjQxcY?t=24s) . (23min) 25 | 26 | ## Express permission to innovate 27 | 28 | Elmyra is not a finalized product idea for consumation only, but a solid, approachable codebase modeling a different paradigm of how industrial/architectural/etc. visualization can be reimagined. You are expressly invited to build on our research and development, reinvent Elmyra's vision, hack it, fork it, extend it, make it your own and share your developments with the community. We're excited to hear what you come up with and glad to help out with know-how on the codebase where we can, feel absolutely free to open an issue and ask for help. 29 | 30 | ## Building from source 31 | 32 | ### Get the source code 33 | 34 | git clone https://github.com/apertus-open-source-cinema/elmyra.git 35 | 36 | ### Download the bundled dependencies and assets 37 | 38 | Download and unzip the [bundled dependencies and assets](http://files.apertus.org/elmyra/elmyra-lib.zip) and put the `lib` folder inside Elmyra's root directory. 39 | 40 | ### Install language dependencies 41 | 42 | Install [rustup](https://rustup.rs/) and [node.js](https://nodejs.org/). 43 | 44 | Inside elmyra's root directory run the following: 45 | 46 | ``` 47 | rustup override set nightly 48 | npm install 49 | ``` 50 | 51 | ### Build and run it 52 | 53 | By running `npm run build` you can now create a build for your platform. The 54 | finished build will be placed under `build/elmyra-[version]-[platform]-[arch]/` and to 55 | start it you simply execute the `elmyra` binary. By default the server listens 56 | on all network interfaces and the automatically assigned port is shown in the 57 | terminal output right on startup. 58 | 59 | Use `./elmyra --help` to read up on available command line options like server 60 | address and port, as well as more advanced/exotic configuration options. 61 | 62 | ## Versioning 63 | 64 | Elmyra uses compatible versioning (ComVer) 65 | 66 | [![ComVer](https://img.shields.io/badge/ComVer-compliant-brightgreen.svg)](https://github.com/staltz/comver) 67 | 68 | ## Acknowledgements 69 | 70 | Elmyra could not have been brought to life without the heart that beats at its core - [Blender](http://blender.org) - and its incredibly helpful and inspiring community; It would not even closely be what it is without [FFmpeg](http://ffmpeg.org) and its hardworking volunteer force; and in the first place, it could not have been conceived without the individuals and organizations that created this open, enabling environment for it to grow in - the AXIOM project. Thanks so much everyone! 71 | -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | const archiver = require('archiver'); 2 | const Bundler = require('parcel-bundler'); 3 | const childProcess = require('child_process'); 4 | const fs = require('fs'); 5 | const fsExtra = require('fs-extra'); 6 | const path = require('path'); 7 | const sass = require('sass'); 8 | 9 | const { version } = require('./package.json'); 10 | 11 | const ARCH = process.arch; 12 | const PLATFORM = { 'linux': 'linux', 'darwin': 'macos', 'win32': 'windows' }[process.platform]; 13 | const VERSION = version.replace(/\.0$/, ''); // truncate from SemVer to ComVer 14 | 15 | const BINARY = PLATFORM === 'windows' ? 'elmyra.exe' : 'elmyra'; 16 | 17 | const PLATFORM_BUILD_DIR = path.join(__dirname, `build/elmyra-${VERSION}-${PLATFORM}-${ARCH}`); 18 | 19 | const assets = () => fsExtra.copy( 20 | path.join(__dirname, 'src/assets/'), 21 | path.join(PLATFORM_BUILD_DIR, 'static/') 22 | ); 23 | 24 | const clean = async () => { 25 | await fsExtra.emptyDir(PLATFORM_BUILD_DIR); 26 | await fsExtra.ensureDir(path.join(PLATFORM_BUILD_DIR, 'static')); 27 | }; 28 | 29 | const css = async () => { 30 | const stylesPath = path.join(PLATFORM_BUILD_DIR, 'static/styles.css'); 31 | 32 | await fsExtra.remove(stylesPath); 33 | 34 | const result = sass.renderSync({ 35 | file: path.join(__dirname, 'src/scss/main.scss'), 36 | outputStyle: 'compressed' 37 | }); 38 | 39 | 40 | await fs.promises.writeFile(stylesPath, result.css); 41 | }; 42 | 43 | const javascript = async () => { 44 | const scriptsPath = path.join(PLATFORM_BUILD_DIR, 'static/scripts.js'); 45 | 46 | await fsExtra.remove(scriptsPath); 47 | 48 | const options = { 49 | outFile: 'scripts.js', 50 | outDir: path.join(PLATFORM_BUILD_DIR, 'static'), 51 | minify: true, 52 | scopeHoist: false, 53 | sourceMaps: false, 54 | target: 'browser', 55 | watch: false 56 | }; 57 | 58 | const bundler = new Bundler(path.join(__dirname, 'src/javascript/main.js'), options); 59 | const bundle = await bundler.bundle(); 60 | }; 61 | 62 | const library = () => Promise.all([ 63 | fsExtra.copy( 64 | path.join(__dirname, `lib/${PLATFORM}`), 65 | path.join(PLATFORM_BUILD_DIR, `lib/${PLATFORM}`), 66 | { filter: src => !src.match(new RegExp(`/${PLATFORM}\/paths\.json$`)) } 67 | ), 68 | fsExtra.copy( 69 | path.join(__dirname, 'lib/elmyra'), 70 | path.join(PLATFORM_BUILD_DIR, 'lib/elmyra') 71 | ) 72 | ]); 73 | 74 | const python = () => fsExtra.copy(path.join(__dirname, 'src/python'), path.join(PLATFORM_BUILD_DIR, 'python')); 75 | 76 | const rust = () => new Promise((resolve, reject) => { 77 | const cargoProcess = childProcess.spawn('cargo', ['build', '--release']); 78 | 79 | const output = []; 80 | cargoProcess.stdout.on('data', data => output.push(data.toString())); 81 | cargoProcess.stderr.on('data', data => output.push(data.toString())); 82 | 83 | cargoProcess.on('close', async code => { 84 | if(code === 0) { 85 | await fsExtra.copy( 86 | path.join(__dirname, `target/release/${BINARY}`), 87 | path.join(PLATFORM_BUILD_DIR, BINARY) 88 | ); 89 | 90 | resolve(); 91 | } else { 92 | reject(`Rust compilation failed: ${output.join('\n')}`); 93 | } 94 | }); 95 | }); 96 | 97 | const package = async platform => { 98 | await new Promise((resolve, reject) => { 99 | const zipPath = `${PLATFORM_BUILD_DIR}.zip`; 100 | const output = fs.createWriteStream(zipPath); 101 | const archive = archiver('zip', { zlib: { level: 9 } }); 102 | 103 | output.on('close', resolve); 104 | 105 | archive.pipe(output); 106 | archive.directory(PLATFORM_BUILD_DIR, false); 107 | archive.finalize(); 108 | }); 109 | }; 110 | 111 | const packageLibrary = () => new Promise((resolve, reject) => { 112 | const output = fs.createWriteStream(path.join(__dirname, `build/elmyra-lib.zip`)); 113 | const archive = archiver('zip', { zlib: { level: 9 } }); 114 | 115 | output.on('close', resolve); 116 | 117 | archive.pipe(output); 118 | archive.directory(path.join(__dirname, 'lib'), 'lib'); 119 | archive.finalize(); 120 | }); 121 | 122 | const build = async () => { 123 | const requested = process.argv.slice(2); 124 | 125 | if(requested.length === 0) { 126 | console.log('No build steps requested (all|clean|css|js|python|rust|library|package|package-lib) - you can provide multiple to mix and match them however needed.'); 127 | return; 128 | } 129 | 130 | await fsExtra.ensureDir(path.join(__dirname, 'build')); 131 | 132 | if(requested.includes('package-lib')) { 133 | await packageLibrary(); 134 | } 135 | 136 | if(requested.includes('package')) { 137 | await clean(); 138 | await Promise.all([assets(), css(), javascript(), library(), python(), rust()]); 139 | await package(); 140 | } else if(requested.includes('all')) { 141 | await clean(); 142 | await Promise.all([assets(), css(), javascript(), library(), python(), rust()]); 143 | } else { 144 | if(requested.includes('clean')) { await clean(); } 145 | 146 | const pending = []; 147 | 148 | if(requested.includes('assets')) { pending.push(assets()); } 149 | if(requested.includes('css')) { pending.push(css()); } 150 | if(requested.includes('javascript')) { pending.push(javascript()); } 151 | if(requested.includes('library')) { pending.push(library()); } 152 | if(requested.includes('python')) { pending.push(python()); } 153 | if(requested.includes('rust')) { pending.push(rust()); } 154 | 155 | await Promise.all(pending); 156 | } 157 | }; 158 | 159 | build(); 160 | -------------------------------------------------------------------------------- /icons/elmyra.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apertus-open-source-cinema/elmyra/b5a16fc266e0496b9b320fec2e378748eb0b00db/icons/elmyra.icns -------------------------------------------------------------------------------- /icons/elmyra.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apertus-open-source-cinema/elmyra/b5a16fc266e0496b9b320fec2e378748eb0b00db/icons/elmyra.ico -------------------------------------------------------------------------------- /icons/elmyra.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apertus-open-source-cinema/elmyra/b5a16fc266e0496b9b320fec2e378748eb0b00db/icons/elmyra.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Simon Repp ", 3 | "bugs": { 4 | "url": "https://github.com/apertus-open-source-cinema/elmyra/issues" 5 | }, 6 | "description": "A blender-based rapid iterative visualization system", 7 | "dependencies": { 8 | "@babel/core": "^7.9.0", 9 | "@babel/preset-react": "^7.9.4", 10 | "@primer/octicons-react": "^9.6.0", 11 | "archiver": "^3.1.1", 12 | "babel-plugin-transform-class-properties": "^6.24.1", 13 | "bootstrap": "^4.4.1", 14 | "clipboard": "^2.0.6", 15 | "fs-extra": "^9.0.0", 16 | "jquery": "^3.5.0", 17 | "moment": "^2.24.0", 18 | "parcel-bundler": "^1.12.4", 19 | "popper.js": "^1.16.1", 20 | "react": "^16.13.1", 21 | "react-dom": "^16.13.1", 22 | "sass": "^1.26.3", 23 | "three": "^0.115.0" 24 | }, 25 | "homepage": "https://github.com/apertus-open-source-cinema/elmyra#readme", 26 | "license": "GPL-3.0-or-later", 27 | "main": "main.js", 28 | "name": "elmyra", 29 | "private": true, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/apertus-open-source-cinema/elmyra.git" 33 | }, 34 | "scripts": { 35 | "build": "node build.js all", 36 | "package": "node build.js package", 37 | "package-lib": "node build.js package-lib" 38 | }, 39 | "version": "1.0.0" 40 | } 41 | -------------------------------------------------------------------------------- /src/assets/checker-active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apertus-open-source-cinema/elmyra/b5a16fc266e0496b9b320fec2e378748eb0b00db/src/assets/checker-active.png -------------------------------------------------------------------------------- /src/assets/checker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apertus-open-source-cinema/elmyra/b5a16fc266e0496b9b320fec2e378748eb0b00db/src/assets/checker.png -------------------------------------------------------------------------------- /src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apertus-open-source-cinema/elmyra/b5a16fc266e0496b9b320fec2e378748eb0b00db/src/assets/favicon.ico -------------------------------------------------------------------------------- /src/assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Elmyra 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/javascript/application.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | 3 | import Navigation from './navigation.js'; 4 | import Preview from './preview.js'; 5 | import Visualization from './index/visualization.js'; 6 | import Wizard from './wizard/wizard.js'; 7 | 8 | const REFRESH_INTERVAL = 2000; 9 | 10 | export default function Application() { 11 | const [preview, setPreview] = useState(null); 12 | const [wizard, showWizard] = useState(false); 13 | const [visualizations, setVisualizations] = useState([]); 14 | 15 | const loadFromServer = () => { 16 | const request = new XMLHttpRequest(); 17 | request.onload = event => setVisualizations(event.target.response); 18 | request.onerror = event => console.error('/__internal/visualizations'); 19 | request.open('GET', '/__internal/visualizations'); 20 | request.responseType = 'json'; 21 | request.send(); 22 | }; 23 | 24 | useEffect(() => { 25 | loadFromServer(); 26 | 27 | const interval = setInterval(loadFromServer, REFRESH_INTERVAL); 28 | 29 | return () => clearInterval(interval); 30 | }, []); 31 | 32 | if(wizard) 33 | return( 34 |
35 | { loadFromServer(); showWizard(false); }} /> 36 |
37 | ); 38 | 39 | return( 40 |
41 | 42 | showWizard(true)}> 43 | New 44 | 45 | 46 | 47 |
48 | {visualizations.map((visualization, index) => 49 | 52 | )} 53 |
54 | 55 | {preview !== null ? 56 | 60 | : null} 61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /src/javascript/index/download_button.js: -------------------------------------------------------------------------------- 1 | import filesize from 'filesize'; 2 | import Octicon from '@primer/octicons-react'; 3 | import React from 'react'; 4 | 5 | import { 6 | DesktopDownload, 7 | FileBinary, 8 | FileCode, 9 | FileMedia, 10 | FileZip, 11 | TriangleRight 12 | } from '@primer/octicons-react'; 13 | 14 | const FORMAT_TOOLTIPS = { 15 | 'png': 'Larger filesizes but lossless, supports transparency - great for reduced, graphic designs', 16 | 'jpg': 'Very small filesizes but lossy, no transparency - great for visually dense, detailed images', 17 | 'svg': 'Vector-based line graphic - only available for illustrated/line-based styles', 18 | 'mp4': 'Most widely spread video format - use this e.g. for uploading to Youtube', 19 | 'ogv': 'An alternative, open format', 20 | 'webm': 'An alternative, open format', 21 | 'gif': 'Bad quality but biggest fun factor', 22 | 'png.zip': 'Larger filesizes but lossless, supports transparency - great for reduced, graphic designs', 23 | 'svg.zip': 'Vector-based line graphic - only available for illustrated/line-based styles' 24 | }; 25 | 26 | function DownloadOption({ format, version, visualization }) { 27 | if(version.meta[format] === null) { 28 | return( 29 |
31 | {format} 32 |
33 | ); 34 | } 35 | 36 | const fileSize = 37 | 38 | ({filesize(version.meta[format].fileSize)}) 39 | 40 | ; 41 | 42 | return( 43 | 47 | {format} {fileSize} 48 | 49 | ); 50 | } 51 | 52 | export default function DownloadButton({ version, visualization }) { 53 | const blendOption = 54 | 58 | blend 59 | 60 | ; 61 | 62 | let downloadOptions; 63 | if(version.meta.mediaAnimated) { 64 | downloadOptions = 65 |
66 |
67 | 3D Scene Files 68 |
69 | {blendOption} 70 |
71 | 72 |
73 | Video Files 74 |
75 | 76 | 77 | 78 | 79 |
80 | 81 |
82 | All Frames as Images 83 |
84 | 85 | 86 |
87 | ; 88 | } else { 89 | downloadOptions = 90 |
91 |
92 | 3D Scene Files 93 |
94 | {blendOption} 95 |
96 | 97 |
98 | Image Files 99 |
100 | 101 | 102 |
103 | 104 |
105 | Vector Files 106 |
107 | 108 |
109 | ; 110 | } 111 | 112 | return( 113 |
114 | 117 | {downloadOptions} 118 |
119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /src/javascript/index/embed_button.js: -------------------------------------------------------------------------------- 1 | import ClipboardJS from 'clipboard'; 2 | import Octicon, { Clippy } from '@primer/octicons-react'; 3 | import React, { useEffect } from 'react'; 4 | 5 | export default function EmbedButton({ versionID, visualization }) { 6 | const link = `${location.origin}/${visualization.id}/${versionID}`; 7 | 8 | useEffect(() => { 9 | const clipboard = new ClipboardJS(`#${visualization.id}-copy`); 10 | 11 | clipboard.on('success', event => { 12 | alert('Copied to clipboard!'); 13 | event.clearSelection(); 14 | }); 15 | 16 | clipboard.on('error', event => 17 | alert(`Could not copy to your clipboard - please select and copy this manually:\n${link}`) 18 | ); 19 | 20 | return () => clipboard.destroy(); 21 | }); 22 | 23 | return( 24 | 28 | Embed 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/javascript/index/processing_state.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | class ProgressBar extends React.Component { 4 | render() { 5 | const progress = (this.props.current / this.props.max) * 100; 6 | 7 | let classes = this.props.classes; 8 | if(typeof(classes) !== 'string') { 9 | classes = classes.join(' '); 10 | } 11 | 12 | return( 13 |
19 | 20 | {this.props.current}/{this.props.max} 21 | 22 |
23 | ); 24 | } 25 | }); 26 | 27 | class ProcessingProgressBar extends React.Component { 28 | render() { 29 | const classes = ['progress-bar', 'progress-bar-striped', 'active']; 30 | 31 | if(this.props.renderQuality === 'production') { 32 | classes.push('progress-bar-success'); 33 | } else if(this.props.renderQuality === 'preview') { 34 | classes.push('progress-bar-warning'); 35 | } else { 36 | classes.push('progress-bar-danger'); 37 | } 38 | 39 | let primaryBar; 40 | let backgroundBar; 41 | if(this.props.mediaAnimated) { 42 | if(this.props.renderedFrames < this.props.mediaFrameCount) { 43 | primaryBar = ; 46 | } else { 47 | primaryBar = ; 50 | 51 | backgroundBar = ; 54 | } 55 | } else { 56 | primaryBar = ; 57 | } 58 | 59 | return( 60 |
61 | {primaryBar} {backgroundBar} 62 |
63 | ); 64 | } 65 | } 66 | 67 | export default class ProcessingState extends React.Component { 68 | render() { 69 | if(typeof(this.props.processing) === "string") { 70 | return( 71 |
72 | 73 |
74 | {this.props.processing} 75 |
76 |
77 | ); 78 | } else { 79 | return ; 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/javascript/index/update_button.js: -------------------------------------------------------------------------------- 1 | import Octicon, { Alert, Check, CloudUpload, X } from '@primer/octicons-react'; 2 | import React from 'react'; 3 | 4 | export default function UpdateButton({ visualization }) { 5 | const uploadSelect = event => { 6 | document.getElementById(`${visualization.id}-upload`).click(); 7 | event.preventDefault(); 8 | }; 9 | 10 | const uploadFailed = event => { 11 | // document.getElementById('flash').innerHTML = '' 12 | }; 13 | 14 | const uploadFinished = event => { 15 | // document.getElementById('flash').innerHTML = '' 16 | }; 17 | 18 | const uploadSubmit = () => { 19 | const file = document.querySelector(`#${visualization.id}-upload`) 20 | .files[0]; 21 | 22 | const request = new XMLHttpRequest(); 23 | request.onload = uploadFinished; 24 | request.onerror = uploadFailed; 25 | request.open('POST', `/__internal/upload/${visualization.id}`); 26 | request.responseType = 'json'; 27 | request.send(file); 28 | }; 29 | 30 | const updateFailed = event => { 31 | // document.getElementById('flash').innerHTML = '' 32 | }; 33 | 34 | const updateFinished = event => { 35 | // document.getElementById('flash').innerHTML = '' 36 | }; 37 | 38 | const updateSubmit = event => { 39 | event.preventDefault(); 40 | 41 | const request = new XMLHttpRequest(); 42 | request.onload = updateFinished; 43 | request.onerror = updateFailed; 44 | request.open('POST', `/__internal/upload/${visualization.id}`); 45 | request.responseType = 'json'; 46 | request.send(); 47 | } 48 | 49 | return( 50 | 72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/javascript/index/version_button.js: -------------------------------------------------------------------------------- 1 | import Octicon, { Lock, Sync, Versions } from '@primer/octicons-react'; 2 | import React from 'react'; 3 | 4 | export default function VersionButton({ setVersionID, versionID, versions }) { 5 | const label = versionID === 'latest' ? 'Latest Version' : `Version ${versionID}`; 6 | 7 | return( 8 | 9 | 12 |
13 |
14 | Dynamic Versions 15 |
16 | 23 | setVersionID('latest-published')}> 26 | Latest Published 27 | 28 |
29 | 30 |
31 | Permanent Versions 32 |
33 | {versions.map(version => 34 | setVersionID(version.id)}> 38 | {version.id} 39 | 40 | )} 41 |
42 |
43 | 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/javascript/index/visualization.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | import Octicon, { DeviceCamera, DeviceCameraVideo, Zap } from '@primer/octicons-react'; 3 | import React, { useState } from 'react'; 4 | 5 | import DownloadButton from './download_button.js'; 6 | import EmbedButton from './embed_button.js'; 7 | import UpdateButton from './update_button.js'; 8 | import VersionButton from './version_button.js'; 9 | 10 | export default function Visualization({ setPreview, visualization }) { 11 | const [versionID, setVersionID] = useState('latest'); 12 | 13 | const version = versionID === 'latest' ? 14 | visualization.versions[0] : 15 | visualization.versions.find(iterated => iterated.id === versionID); 16 | 17 | const { meta } = version; 18 | 19 | const title = []; 20 | 21 | if(meta.mediaWidth !== null && meta.mediaHeight !== null) { 22 | title.push(`${meta.mediaWidth}x${meta.mediaHeight}`); 23 | } 24 | 25 | if(meta.lastRenderedSamples !== null) { 26 | title.push(`${meta.lastRenderedSamples} samples`); 27 | } 28 | 29 | if(meta.mediaAnimated) { 30 | const duration = moment.duration(meta.mediaLength, 'seconds'); 31 | title.push(moment.utc(duration.asMilliseconds()).format("mm:ss")); 32 | } 33 | 34 | let previewImage; 35 | const previewClasses = ['preview']; 36 | if(meta.lastRender === null) { 37 | previewClasses.push('pending'); 38 | previewImage = 'Rendering soon'; 39 | } else { 40 | const lastRender = moment(meta.lastRender).unix(); 41 | previewImage = ; 43 | } 44 | 45 | let processing; 46 | if(typeof(meta.processing) === 'string') { 47 | previewClasses.push('active'); 48 | processing = 49 | 51 | 52 | 53 | ; 54 | 55 | if(meta.lastRender === null) { 56 | previewImage = 'Now rendering'; 57 | } 58 | } 59 | 60 | let overlayClasses, overlayIcon; 61 | if(meta.mediaAnimated) { 62 | overlayClasses = 'overlay animation'; 63 | overlayIcon = ; 64 | } else { 65 | overlayClasses = 'overlay still'; 66 | overlayIcon = ; 67 | } 68 | 69 | return( 70 |
71 | 80 | 81 |
82 |
83 |
84 | {visualization.id} {processing} 85 |
86 | 87 | 90 | 91 |
92 | 93 | {/* TODO: Show render quality in conjunction with samples */} 94 | {/* TODO: */} 95 |
96 | 97 | 98 | 99 |
100 |
101 |
102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /src/javascript/main.js: -------------------------------------------------------------------------------- 1 | import 'bootstrap'; 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | 6 | import Application from './application.js'; 7 | 8 | const reactEntry = document.createElement('div'); 9 | document.body.appendChild(reactEntry); 10 | 11 | ReactDOM.render(, reactEntry); 12 | -------------------------------------------------------------------------------- /src/javascript/navigation.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class Navigation extends React.Component { 4 | render() { 5 | return( 6 | 13 | ); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/javascript/preview.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | 3 | export default function Preview({ setPreview, version, versionID, visualization }) { 4 | useEffect(() => { 5 | const escape = event => { 6 | if(event.key === 'Escape') { setPreview(null); } 7 | }; 8 | 9 | document.addEventListener('keydown', escape); 10 | 11 | return document.removeEventListener('keydown', escape); 12 | }); 13 | 14 | const url = `/${visualization.id}/${versionID}?${Date.now()}`; 15 | 16 | return( 17 |
setPreview(null)} > 18 |
event.stopPropagation()}> 19 | {version.meta.mediaAnimated ? 20 | 23 | : 24 | 25 | } 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/javascript/wizard/animated_cross_section.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import ID from './id.js'; 4 | 5 | export default class AnimatedCrossSection extends React.Component { 6 | static navigationTitle = 'Animated Cross Section'; 7 | 8 | constructor(props) { 9 | super(props); 10 | this.state = { 11 | axis: 'z', 12 | levelFrom: 0, 13 | levelTo: 100, 14 | }; 15 | } 16 | 17 | changeAxis = event => { 18 | this.setState({ axis: event.target.value }); 19 | } 20 | 21 | changeLevelFrom = event => { 22 | const newLevelFrom = parseInt(event.target.value); 23 | const levelTo = this.state.levelTo; 24 | 25 | this.setState({ 26 | levelFrom: newLevelFrom, 27 | levelTo: levelTo < newLevelFrom ? newLevelFrom : levelTo 28 | }); 29 | } 30 | 31 | changeLevelTo = event => { 32 | const levelFrom = this.state.levelFrom; 33 | const newLevelTo = parseInt(event.target.value); 34 | 35 | this.setState({ 36 | levelFrom: levelFrom > newLevelTo ? newLevelTo : levelFrom, 37 | levelTo: newLevelTo 38 | }); 39 | } 40 | 41 | render() { 42 | return( 43 |
44 | 45 |
46 |

47 | Animated Cross Section 48 |

49 | 50 |
51 | Begin Level at which to cut through the mesh: 52 | 53 | 61 | 62 | {this.state.levelFrom}% 63 | 64 |

65 | 66 | End Level at which to cut through the mesh: 67 | 68 | 76 | 77 | {this.state.levelTo}% 78 | 79 |

80 | 81 | Axis:   82 | 83 |
84 | 90 | 91 |
92 | 93 |
94 | 100 | 101 |
102 | 103 |
104 | 110 | 111 |
112 |
113 | 114 |
115 | 118 |
119 |
120 | 121 |
122 | ); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/javascript/wizard/camera_type.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import StyleType from './style_type.js'; 4 | 5 | export default class CameraType extends React.Component { 6 | static navigationTitle = 'Camera'; 7 | 8 | render() { 9 | return( 10 |
11 | {!this.props.mediaAnimated ? 12 |
13 |

Fixed

14 | 15 |
16 | The camera is fixed to exactly one angle. 17 |
18 | 19 |
20 | 23 |
24 |
25 | : null} 26 | 27 | {this.props.mediaAnimated ? 28 |
29 |

Turntable

30 | 31 |
32 | The camera turns 360 degrees around the object, staying on the same plane. Another way to imagine it, is that the camera were fixed, and the object placed on a table that turns 360 degrees. 33 |
34 | 35 |
36 | 39 |
40 |
41 | : null} 42 | 43 | {this.props.mediaAnimated ? 44 |
45 |

Helix

46 | 47 |
48 | Like Turntable, but the camera also moves from below to above the object while it is turning around the object. If you trace the path the camera makes, it forms a helix, as it occurs in DNA strands. 49 |
50 | 51 |
52 | 55 |
56 |
57 | : null} 58 |
59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/javascript/wizard/cross_section.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import ID from './id.js'; 4 | 5 | export default class CrossSection extends React.Component { 6 | static navigationTitle = 'Cross Section'; 7 | 8 | constructor(props) { 9 | super(props); 10 | this.state = { 11 | axis: 'z', 12 | level: 50 13 | }; 14 | } 15 | 16 | changeAxis = event => { 17 | this.setState({ axis: event.target.value }); 18 | } 19 | 20 | changeLevel = event => { 21 | this.setState({ level: parseInt(event.target.value) }); 22 | } 23 | 24 | render() { 25 | return( 26 |
27 | 28 |
29 |

Cross Section

30 | 31 |
32 | Level at which to cut through the mesh: 33 | 34 | 42 | 43 | {this.state.level}% 44 | 45 |

46 | 47 | Axis:   48 | 49 |
50 | 56 | 57 |
58 | 59 |
60 | 66 | 67 |
68 | 69 |
70 | 76 | 77 |
78 |
79 | 80 |
81 | 84 |
85 |
86 |
87 | ); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/javascript/wizard/id.js: -------------------------------------------------------------------------------- 1 | import Octicon, { Info } from '@primer/octicons-react'; 2 | import React from 'react'; 3 | 4 | export default class ID extends React.Component { 5 | static navigationTitle = 'ID'; 6 | 7 | constructor(props) { 8 | super(props); 9 | this.state = { id: '' }; 10 | } 11 | 12 | componentDidMount() { 13 | document.getElementById('id').focus(); 14 | } 15 | 16 | changeID = event => { 17 | this.setState({ id: event.target.value }); 18 | } 19 | 20 | submitID = event => { 21 | if(document.getElementById('id').checkValidity()) { 22 | const submitButton = document.getElementById('id-submit'); 23 | 24 | submitButton.classList.remove('btn-primary'); 25 | submitButton.classList.add('btn-warning'); 26 | submitButton.setAttribute('disabled', true); 27 | submitButton.innerHTML = 'Generating ...'; 28 | 29 | this.props.generate(this.state.id); 30 | } 31 | 32 | event.preventDefault(); 33 | } 34 | 35 | render() { 36 | return( 37 |
38 |
39 |

ID

40 | 41 | 42 | Only small letters (a-z), digits (0-9) and dashes (-) allowed. 43 | 44 | 45 |
46 | The ID should hint at the content of the visualization, 47 | it will be used to name the visualization in the overview, 48 | as well as in all URLs for embedding the visualization: 49 | 50 |

51 | 52 |
53 | 54 | 63 | 64 | 69 | 70 |
71 |
72 |
73 |
74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/javascript/wizard/import.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Orient from './orient.js'; 4 | 5 | const SUPPORTED_FORMATS = [ 6 | '3ds', 7 | 'blend', 8 | 'dae', 9 | 'fbx', 10 | 'obj', 11 | 'ply', 12 | 'stl' 13 | ]; 14 | 15 | export default class Import extends React.Component { 16 | static navigationTitle = 'Import'; 17 | 18 | constructor(props) { 19 | super(props); 20 | this.state = { 21 | dragRegistered: false, 22 | stage: null, 23 | url: '', 24 | }; 25 | } 26 | 27 | componentDidMount() { 28 | document.getElementById('import-url').focus(); 29 | } 30 | 31 | changeUrl = event => { 32 | this.setState({ url: event.target.value.trim() }); 33 | } 34 | 35 | importFailed = () => { 36 | alert('Failed to import the visualization.\n\nMake sure the URL directly points to a download of your 3D model. Especially\nwhen pasting a URL from github make sure to copy the raw link to the file\nand not the link to the page that shows the model in the browser! also\nmake sure to include http(s):// in the url!'); 37 | 38 | console.error('/__internal/import'); 39 | 40 | this.setState({ stage: 'failed' }); 41 | } 42 | 43 | importFinished = event => { 44 | this.props.navigate(Orient, { importId: event.target.response.importId }); 45 | } 46 | 47 | dragEnter = () => { 48 | this.setState({ dragRegistered: true }); 49 | } 50 | 51 | dragOver = event => { 52 | event.preventDefault(); 53 | } 54 | 55 | drop = event => { 56 | event.preventDefault(); 57 | 58 | const dt = event.dataTransfer 59 | const file = dt.items ? dt.items[0].getAsFile() : dt.files[0]; 60 | 61 | const extension = this.checkExtension(file.name); 62 | 63 | if(extension === null) return; 64 | 65 | this.setState({ stage: 'importing' }); 66 | 67 | const request = new XMLHttpRequest(); 68 | request.onload = this.importFinished; 69 | request.onerror = this.importFailed; 70 | request.open('POST', `/__internal/import/file/${extension}`); 71 | request.responseType = 'json'; 72 | request.send(file); 73 | } 74 | 75 | dragLeave = () => { 76 | this.setState({ dragRegistered: false }); 77 | } 78 | 79 | importFailed = () => { 80 | alert('Failed to import the visualization.\n\nMake sure the URL directly points to a download of your 3D model. Especially\nwhen pasting a URL from github make sure to copy the raw link to the file\nand not the link to the page that shows the model in the browser! also\nmake sure to include http(s):// in the url!'); 81 | 82 | console.error('/__internal/import', status, error.toString()); 83 | 84 | this.setState({ stage: 'failed' }); 85 | } 86 | 87 | importFinished = event => { 88 | this.props.navigate(Orient, { importId: event.target.response.importId }); 89 | } 90 | 91 | importSubmit = event => { 92 | event.preventDefault(); 93 | 94 | if(!document.getElementById('import-url').checkValidity()) 95 | return; 96 | 97 | const extension = this.checkExtension(this.state.url); 98 | 99 | if(extension === null) return; 100 | 101 | this.setState({ stage: 'importing' }); 102 | 103 | const formData = new FormData(); 104 | formData.append('url', this.state.url); 105 | 106 | const request = new XMLHttpRequest(); 107 | request.onload = this.importFinished; 108 | request.onerror = this.importFailed; 109 | request.open('POST', `/__internal/import/url/${extension}`); 110 | request.responseType = 'json'; 111 | request.send(formData); 112 | } 113 | 114 | selectFile = () => { 115 | document.getElementById('select-file-dialog').click(); 116 | } 117 | 118 | checkExtension = url => { 119 | let extension = url.match(/\.[A-Za-z0-9]+$/); 120 | 121 | if(extension === null) { 122 | alert('The url does not end with a recognized file extension to identify the file format by.'); 123 | return null; 124 | } 125 | 126 | extension = extension[0].slice(1).toLowerCase(); // remove dot, normalize case 127 | 128 | if(!SUPPORTED_FORMATS.includes(extension)) { 129 | alert(`The file extension .${extension} is not supported.`); 130 | return null; 131 | } 132 | 133 | return extension; 134 | } 135 | 136 | selectFileSubmit = () => { 137 | const file = document.getElementById('select-file-dialog').files[0]; 138 | 139 | const extension = this.checkExtension(file.name); 140 | 141 | if(extension === null) return; 142 | 143 | this.setState({ stage: 'importing' }); 144 | 145 | const request = new XMLHttpRequest(); 146 | request.onload = this.importFinished; 147 | request.onerror = this.importFailed; 148 | request.open('POST', `/__internal/import/file/${extension}`); 149 | request.responseType = 'json'; 150 | request.send(file); 151 | } 152 | 153 | render() { 154 | let import_btn_classes, import_btn_text; 155 | if(this.state.stage == 'importing') { 156 | import_btn_classes = 'btn btn-warning'; 157 | import_btn_text = ' Importing ...'; 158 | } else if(this.state.stage == 'failed') { 159 | import_btn_classes = 'btn btn-danger'; 160 | import_btn_text = 'Import failed - Retry'; 161 | } else { 162 | import_btn_classes = 'btn btn-primary'; 163 | import_btn_text = 'Import'; 164 | } 165 | 166 | return( 167 |
168 |
169 |

Import model

170 | 171 |
172 | Supported formats: .3ds, .blend, .dae, .fbx, .obj, .ply, .stl 173 | 174 |

175 | 176 |
183 | Drag and drop a file or click to open a file select dialog 184 |
185 | 186 |

187 | 188 |
189 | 190 | 198 | 199 | 204 | 205 | 210 | 211 |
212 |
213 |
214 |
215 | ); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/javascript/wizard/media_length.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import MediaResolution from './media_resolution.js'; 4 | 5 | export default class MediaLength extends React.Component { 6 | static navigationTitle = 'Length'; 7 | 8 | constructor(props) { 9 | super(props); 10 | this.state = { length: 6 }; 11 | } 12 | 13 | changeLength = event => { 14 | this.setState({ length: parseInt(event.target.value) }); 15 | } 16 | 17 | render() { 18 | return( 19 |
20 |
21 |

Length

22 | 23 |
24 | Keep in mind that longer animations take longer to render. 25 | 26 | 34 | 35 | {this.state.length} seconds 36 |
37 | 38 |
39 | 42 |
43 |
44 |
45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/javascript/wizard/media_resolution.js: -------------------------------------------------------------------------------- 1 | import Octicon, { Clippy, Info } from '@primer/octicons-react'; 2 | import React from 'react'; 3 | 4 | import CameraType from './camera_type.js'; 5 | 6 | export default class MediaResolution extends React.Component { 7 | static navigationTitle = 'Resolution'; 8 | 9 | constructor(props) { 10 | super(props); 11 | this.state = { 12 | height: 640, 13 | width: 480 14 | }; 15 | } 16 | 17 | changeWidth = event => { 18 | this.setState({ width: parseInt(event.target.value) }); 19 | } 20 | 21 | changeHeight = event => { 22 | this.setState({ height: parseInt(event.target.value) }); 23 | } 24 | 25 | render() { 26 | return( 27 |
28 | 29 |
30 |

DCI 4K

31 | 32 |
33 | This is the film and video production industry 4K standard. 34 |
35 | 36 |
37 | 40 |
41 |
42 | 43 |
44 |

UHD-1

45 | 46 | Also known as UHDTV, 2160p 47 | 48 | 49 |
50 | This is the consumer 4K standard, use it for material you want to show on consumer display devices, television and also for Youtube. 51 |
52 | 53 |
54 | 57 |
58 |
59 | 60 |
61 |

1080p

62 | 63 | Also known as Full HD, FHD. 64 | 65 | 66 |
67 | 68 |
69 | 72 |
73 |
74 | 75 |
76 |

720p

77 | 78 |
79 | It renders quicker than 1080p. 80 |
81 | 82 |
83 | 86 |
87 |
88 | 89 |
90 |

Custom

91 | 92 |
93 |
94 | 99 |
100 | 105 |
106 |
107 | 108 |
109 | 112 |
113 |
114 |
115 | ); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/javascript/wizard/media_type.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import MediaLength from './media_length.js'; 4 | import MediaResolution from './media_resolution.js'; 5 | 6 | export default class MediaType extends React.Component { 7 | static navigationTitle = 'Media'; 8 | 9 | render() { 10 | return( 11 |
12 |
13 |

Image

14 | 15 |
16 | A rendered still image. 17 |
18 | 19 |
20 | 23 |
24 |
25 | 26 |
27 |

Animation

28 | 29 |
30 | A rendered animation. 31 |
32 | 33 |
34 | 37 |
38 |
39 |
40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/javascript/wizard/modifier_type.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import AnimatedCrossSection from './animated_cross_section.js'; 4 | import CrossSection from './cross_section.js'; 5 | import ID from './id.js'; 6 | 7 | export default class ModifierType extends React.Component { 8 | static navigationTitle = 'Modifier'; 9 | 10 | render() { 11 | return( 12 |
13 |
14 |

None

15 | 16 |
17 | Do not use any modifier. 18 |
19 | 20 |
21 | 24 |
25 |
26 | 27 |
28 |

Cross Section

29 | 30 |
31 | You can think about it as cutting your model in half and throwing away one of the pieces, so the insides of the other show. 32 |
33 | 34 |
35 | 38 |
39 |
40 | 41 |
42 |

Animated Cross Section

43 | 44 |
45 | Same as Cross Section, only the plane where you cut moves at each frame, allowing you to see through your whole model over the course of the animation. 46 |
47 | 48 |
49 | 52 |
53 |
54 |
55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/javascript/wizard/orient.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | AmbientLight, 4 | AxesHelper, 5 | Color, 6 | DoubleSide, 7 | Geometry, 8 | Mesh, 9 | PerspectiveCamera, 10 | PointLight, 11 | Quaternion, 12 | Scene, 13 | Vector3, 14 | WebGLRenderer 15 | } from 'three'; 16 | import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'; 17 | 18 | import MediaType from './media_type.js'; 19 | 20 | const RED_HEX = 0xff414e; 21 | const GREEN_HEX = 0x9bff46; 22 | const BLUE_HEX = 0x59e0ff; 23 | 24 | export default class Orient extends React.Component { 25 | static navigationTitle = 'Orient'; 26 | 27 | camera = null; 28 | geometry = null; 29 | light = null; 30 | material = null; 31 | mesh = null; 32 | renderer = null; 33 | scene = null; 34 | widgetFlipHorizontally = null; 35 | widgetFlipVertically = null; 36 | widgetTilt = null; 37 | widgetTurn = null; 38 | 39 | constructor(props) { 40 | super(props); 41 | this.state = { 42 | flipHorizontally: false, 43 | flipVertically: false, 44 | }; 45 | } 46 | 47 | componentDidMount() { 48 | this.scene = new Scene(); 49 | 50 | this.scene.add(new AxesHelper(1)); 51 | 52 | this.camera = new PerspectiveCamera(16, 720 / 480, 1, 20); 53 | this.camera.position.set(7.2706032, -3.5676303, 2.6419632); 54 | this.camera.up = new Vector3(0,0,1); 55 | this.camera.lookAt(new Vector3(0, 0, 0)); 56 | this.scene.add(this.camera); 57 | 58 | this.light = new AmbientLight(0x404040); 59 | this.scene.add(this.light); 60 | 61 | this.light = new PointLight(RED_HEX, 1, 10); 62 | this.light.position.set(3, 0 , 0); 63 | this.scene.add(this.light); 64 | 65 | this.light = new PointLight(GREEN_HEX, 1, 10); 66 | this.light.position.set(0, 3 , 0); 67 | this.scene.add(this.light); 68 | 69 | this.light = new PointLight(BLUE_HEX, 1, 10); 70 | this.light.position.set(0, 0 , 3); 71 | this.scene.add(this.light); 72 | 73 | this.renderer = new WebGLRenderer({ antialias: true }); 74 | this.renderer.setSize(720, 480); 75 | this.renderer.setClearColor(0xffffff); 76 | 77 | document.getElementById('viewer-container').appendChild(this.renderer.domElement); 78 | 79 | // Render white background until obj is loaded 80 | this.renderer.render(this.scene, this.camera); 81 | 82 | const loader = new OBJLoader(); 83 | 84 | loader.load(`/__internal/preview/${this.props.importId}`, root => { 85 | for(const child of root.children) { 86 | if(child.name == 'widget-flip-horizontally' || 87 | child.name == 'widget-flip-vertically' || 88 | child.name == 'widget-tilt' || 89 | child.name == 'widget-turn') { 90 | 91 | child.material.color = new Color(0x000000); 92 | child.material.emissiveIntensity = 1; 93 | child.material.side = DoubleSide; 94 | child.visible = false; 95 | 96 | if(child.name == 'widget-flip-horizontally') { 97 | child.material.emissive = new Color(GREEN_HEX); 98 | this.widgetFlipHorizontally = child; 99 | } else if(child.name == 'widget-flip-vertically') { 100 | child.material.emissive = new Color(BLUE_HEX); 101 | this.widgetFlipVertically = child; 102 | } else if(child.name == 'widget-tilt') { 103 | child.material.emissive = new Color(RED_HEX); 104 | this.widgetTilt = child; 105 | } else if(child.name == 'widget-turn') { 106 | child.material.emissive = new Color(BLUE_HEX); 107 | this.widgetTurn = child; 108 | } 109 | } else { 110 | const geometry = new Geometry().fromBufferGeometry(child.geometry); 111 | const material = child.material; 112 | material.color = new Color(0xffffff); 113 | material.side = DoubleSide; 114 | this.mesh = new Mesh(geometry, child.material); 115 | } 116 | } 117 | 118 | this.scene.add(this.widgetFlipHorizontally); 119 | this.scene.add(this.widgetFlipVertically); 120 | this.scene.add(this.widgetTilt); 121 | this.scene.add(this.widgetTurn); 122 | this.scene.add(this.mesh); 123 | 124 | this.renderer.render(this.scene, this.camera); 125 | }); 126 | } 127 | 128 | flipNormals = () => { 129 | this.mesh.geometry.dynamic = true 130 | this.mesh.geometry.__dirtyVertices = true; 131 | this.mesh.geometry.__dirtyNormals = true; 132 | 133 | for(let f = 0; f < this.mesh.geometry.faces.length; f++) { 134 | this.mesh.geometry.faces[f].normal.x *= -1; 135 | this.mesh.geometry.faces[f].normal.y *= -1; 136 | this.mesh.geometry.faces[f].normal.z *= -1; 137 | } 138 | 139 | this.mesh.geometry.computeVertexNormals(); 140 | this.mesh.geometry.computeFaceNormals(); 141 | } 142 | 143 | flipHorizontally = () => { 144 | this.setState({ flipHorizontally: !this.state.flipHorizontally }); 145 | this.mesh.geometry.scale(1, -1, 1); 146 | this.flipNormals(); 147 | this.renderer.render(this.scene, this.camera); 148 | } 149 | 150 | flipVertically = () => { 151 | this.setState({ flipVertically: !this.state.flipVertically }); 152 | this.mesh.geometry.scale(1, 1, -1); 153 | this.flipNormals(); 154 | this.renderer.render(this.scene, this.camera); 155 | } 156 | 157 | tilt = () => { 158 | const q = new Quaternion(); 159 | q.setFromAxisAngle(new Vector3(1, 0, 0), (90 * Math.PI) / 180); 160 | this.mesh.quaternion.multiplyQuaternions(q, this.mesh.quaternion); 161 | this.renderer.render(this.scene, this.camera); 162 | } 163 | 164 | turn = () => { 165 | const q = new Quaternion(); 166 | q.setFromAxisAngle(new Vector3(0, 0, 1), (90 * Math.PI) / 180); 167 | this.mesh.quaternion.multiplyQuaternions(q, this.mesh.quaternion); 168 | this.renderer.render(this.scene, this.camera); 169 | } 170 | 171 | highlight = (widget, state) => { 172 | if(widget == 'flipHorizontally') { 173 | widget = this.widgetFlipHorizontally.visible = state; 174 | } else if(widget == 'flipVertically') { 175 | widget = this.widgetFlipVertically.visible = state; 176 | } else if(widget == 'turn') { 177 | widget = this.widgetTurn.visible = state; 178 | } else if(widget == 'tilt') { 179 | widget = this.widgetTilt.visible = state; 180 | } 181 | 182 | this.renderer.render(this.scene, this.camera); 183 | } 184 | 185 | saveTransformAndNavigate = () => { 186 | this.props.navigate( 187 | MediaType, 188 | { 189 | orientFlipHorizontally: this.state.flipHorizontally, 190 | orientFlipVertically: this.state.flipVertically, 191 | orientRotateX: this.mesh.rotation._x, 192 | orientRotateY: this.mesh.rotation._y, 193 | orientRotateZ: this.mesh.rotation._z 194 | } 195 | ); 196 | } 197 | 198 | render() { 199 | return( 200 |
201 |
202 |

Orient

203 | 204 |
205 | Use the flip, tilt and turn tools to orient your model such that ...
206 | - the front faces towards the camera along the red axis
207 | - the top faces upwards along the blue axis
208 | - the right side faces away from the camera along the green axis
209 | 210 |

211 | 212 |
213 | 214 |

215 | 216 |
217 | 223 | 224 | 230 | 231 | 237 | 238 | 244 |
245 |
246 | 247 |
248 |
249 | 250 | 255 |
256 |
257 |
258 | ); 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /src/javascript/wizard/style_type.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import ModifierType from './modifier_type.js'; 4 | 5 | export default class StyleType extends React.Component { 6 | static navigationTitle = 'Style'; 7 | 8 | render() { 9 | return( 10 |
11 |
12 |

Realistic

13 | 14 |
15 | A realistically-oriented style. 16 |
17 | 18 |
19 | 22 |
23 |
24 | 25 |
26 |

Illustrated

27 | 28 |
29 | A blueprint-like, line-based aesthetic. 30 |
31 | 32 |
33 | 36 |
37 |
38 |
39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/javascript/wizard/wizard.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Import from './import.js'; 4 | import Navigation from '../navigation.js'; 5 | 6 | const GENERATE_PARAMS = [ 7 | 'mediaAnimated', 8 | 'mediaLength', 9 | 'mediaWidth', 10 | 'mediaHeight', 11 | 'importId', 12 | 'orientFlipHorizontally', 13 | 'orientFlipVertically', 14 | 'orientRotateX', 15 | 'orientRotateY', 16 | 'orientRotateZ', 17 | 'cameraType', 18 | 'styleType', 19 | 'modifierType', 20 | 'modifierSectionAxis', 21 | 'modifierSectionLevel', 22 | 'modifierSectionLevelFrom', 23 | 'modifierSectionLevelTo' 24 | ]; 25 | 26 | export default class Wizard extends React.Component { 27 | constructor(props) { 28 | super(props); 29 | this.state = { steps: [Import] }; 30 | } 31 | 32 | navigate = (targetStep, stateChanges) => { 33 | const previousStepIndex = this.state.steps.indexOf(targetStep); 34 | if(previousStepIndex === -1) { 35 | stateChanges.steps = this.state.steps.concat(targetStep); 36 | } else { 37 | stateChanges.steps = this.state.steps.slice(0, previousStepIndex + 1); 38 | } 39 | 40 | this.setState(stateChanges); 41 | } 42 | 43 | generate = id => { 44 | const requestData = {}; 45 | 46 | requestData['id'] = id; 47 | 48 | GENERATE_PARAMS.forEach(param => { 49 | if(this.state[param] !== undefined) { 50 | requestData[param] = this.state[param]; 51 | } 52 | }); 53 | 54 | const request = new XMLHttpRequest(); 55 | request.onload = this.generateFinished; 56 | request.onerror = this.generateFailed; 57 | request.open('POST', '/__internal/generate'); 58 | request.setRequestHeader('Content-Type', 'application/json; charset=utf-8'); 59 | request.responseType = 'json'; 60 | request.send(JSON.stringify(requestData)); 61 | 62 | this.setState({ id: id }); 63 | } 64 | 65 | generateFinished = () => { 66 | this.props.showIndex(); 67 | } 68 | 69 | generateFailed = () => { 70 | alert('Failed to generate the visualization'); 71 | } 72 | 73 | render() { 74 | const CurrentStep = this.state.steps[this.state.steps.length - 1]; 75 | 76 | return( 77 |
78 | 79 | 80 | Cancel 81 | 82 | 83 | {this.state.steps.map((step, index) => 84 | 88 | {index + 1} {step.navigationTitle} 89 | 90 | )} 91 | 92 | 93 | 94 |
95 | ); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/python/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apertus-open-source-cinema/elmyra/b5a16fc266e0496b9b320fec2e378748eb0b00db/src/python/__init__.py -------------------------------------------------------------------------------- /src/python/generate.py: -------------------------------------------------------------------------------- 1 | """ 2 | Generates and saves a new visualization 3 | 4 | Custom arguments: 5 | title -- the title 6 | id -- the visualization identifier (only alphanumeric characters and dashes) 7 | TODO 8 | """ 9 | 10 | import sys 11 | 12 | from argparse import ArgumentParser 13 | from os import chdir, path 14 | 15 | elmyra_root = path.dirname(path.realpath(__file__)) 16 | 17 | # Make this script's directory the current working directory (could be anything else) 18 | # and add it to sys.path (this script runs from blender context) 19 | elmyra_root = path.dirname(path.realpath(__file__)) 20 | chdir(elmyra_root) 21 | sys.path.append(elmyra_root) 22 | 23 | from lib import common, camera, media, meta, modifier, style, update, version 24 | 25 | 26 | def parse_custom_args(): 27 | parser = ArgumentParser(prog="Elmyra Generate Params") 28 | 29 | parser.add_argument("--id", required=True) 30 | parser.add_argument("--import-id", required=True) 31 | 32 | parser.add_argument("--media-animated", required=True) 33 | parser.add_argument("--media-width", type=int, required=True) 34 | parser.add_argument("--media-height", type=int, required=True) 35 | parser.add_argument("--media-length", type=float, default=24) 36 | 37 | parser.add_argument("--orient-flip-horizontally", required=True) 38 | parser.add_argument("--orient-flip-vertically", required=True) 39 | parser.add_argument("--orient-rotate-x", type=float, required=True) 40 | parser.add_argument("--orient-rotate-y", type=float, required=True) 41 | parser.add_argument("--orient-rotate-z", type=float, required=True) 42 | 43 | parser.add_argument("--camera-type", required=True) 44 | 45 | parser.add_argument("--style-type", required=True) 46 | 47 | parser.add_argument("--modifier-type", required=True) 48 | parser.add_argument("--modifier-section-axis", default="Z") 49 | parser.add_argument("--modifier-section-level", type=float, default=0.5) 50 | parser.add_argument("--modifier-section-level-from", type=float, default=0) 51 | parser.add_argument("--modifier-section-level-to", type=float, default=1) 52 | 53 | custom_args = sys.argv[sys.argv.index("--") + 1:] 54 | 55 | return parser.parse_args(custom_args) 56 | 57 | args = parse_custom_args() 58 | 59 | common.ensure_addons() 60 | common.empty_scene() 61 | common.setup_scene_defaults() 62 | 63 | update.import_scene(args.import_id, 64 | args.orient_flip_horizontally, 65 | args.orient_flip_vertically, 66 | args.orient_rotate_x, 67 | args.orient_rotate_y, 68 | args.orient_rotate_z) 69 | 70 | media.setup(args.media_animated, 71 | args.media_width, 72 | args.media_height, 73 | args.media_length) 74 | 75 | style.setup(args.style_type) 76 | modifier.setup(args) 77 | camera.setup(args) 78 | 79 | version.save_new(args.id) 80 | meta.write_media_info() 81 | -------------------------------------------------------------------------------- /src/python/import.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Imports from a URL and saves a new temp file for import 3 | 4 | Custom arguments: 5 | id -- the import scene identifier 6 | url -- the url to the import file 7 | ''' 8 | 9 | import sys 10 | 11 | from argparse import ArgumentParser 12 | from os import chdir, path 13 | 14 | # Make this script's directory the current working directory (could be anything else) 15 | # and add it to sys.path (this script runs from blender context) 16 | elmyra_root = path.dirname(path.realpath(__file__)) 17 | chdir(elmyra_root) 18 | sys.path.append(elmyra_root) 19 | 20 | from lib import common, update 21 | 22 | 23 | def parse_custom_args(): 24 | parser = ArgumentParser(prog='Elmyra Import Params') 25 | 26 | parser.add_argument('--import-id', required=True) 27 | parser.add_argument('--url', required=True) 28 | parser.add_argument('--format', required=True) 29 | 30 | custom_args = sys.argv[sys.argv.index('--') + 1:] 31 | 32 | return parser.parse_args(custom_args) 33 | 34 | args = parse_custom_args() 35 | 36 | common.empty_scene() 37 | update.import_model(args) 38 | -------------------------------------------------------------------------------- /src/python/lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apertus-open-source-cinema/elmyra/b5a16fc266e0496b9b320fec2e378748eb0b00db/src/python/lib/__init__.py -------------------------------------------------------------------------------- /src/python/lib/camera.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from functools import reduce 3 | from mathutils import Vector 4 | 5 | from lib.common import get_view3d_context 6 | 7 | 8 | def align_info(): 9 | corners = [] 10 | for obj in bpy.data.objects: 11 | for corner in obj.bound_box: 12 | corners.append(obj.matrix_world @ Vector(corner)) 13 | 14 | center = reduce(lambda m, n: m + n, corners) / len(corners) 15 | radius = max([(corner - center).length for corner in corners]) 16 | 17 | return center, radius 18 | 19 | 20 | def create_camera_rig(): 21 | bpy.ops.curve.primitive_bezier_circle_add(location=(0, 0, 0), radius=1) 22 | bpy.context.object.name = 'elmyra_camera_rig_mount' 23 | 24 | bpy.ops.object.empty_add(location=(0, 0, 0), type='PLAIN_AXES') 25 | bpy.context.object.name = 'elmyra_camera_rig_target' 26 | 27 | bpy.ops.object.camera_add(location=(0, 0, 0)) 28 | bpy.context.object.name = 'elmyra_camera_rig_camera' 29 | 30 | bpy.ops.object.constraint_add(type='FOLLOW_PATH') 31 | bpy.context.object.constraints['Follow Path'].target = bpy.data.objects['elmyra_camera_rig_mount'] 32 | bpy.context.object.constraints['Follow Path'].name = 'elmyra_follow_mount' 33 | 34 | bpy.ops.object.constraint_add(type='TRACK_TO') 35 | bpy.context.object.constraints['Track To'].target = bpy.data.objects['elmyra_camera_rig_target'] 36 | bpy.context.object.constraints['Track To'].track_axis = 'TRACK_NEGATIVE_Z' 37 | bpy.context.object.constraints['Track To'].up_axis = 'UP_Y' 38 | bpy.context.object.constraints['Track To'].name = 'elmyra_track_target' 39 | 40 | 41 | def fixed(options): 42 | # TODO: Let the user position the camera in the browser, use his values 43 | center, radius = align_info() 44 | 45 | bpy.ops.object.camera_add(location=(0, 0, 0)) 46 | bpy.context.object.name = 'elmyra_camera' 47 | 48 | bpy.data.objects['elmyra_camera'].data.clip_start = 0.01 49 | bpy.data.objects['elmyra_camera'].data.clip_end = radius * 9 50 | bpy.context.scene.camera = bpy.data.objects['elmyra_camera'] 51 | 52 | bpy.data.objects['elmyra_camera'].location = (center.x + radius * 3, center.y, center.z + radius * 1.4) 53 | 54 | # Auto fit to viewport 55 | view3d_context = get_view3d_context() 56 | 57 | for obj in bpy.data.objects: 58 | obj.select_set(True) 59 | 60 | bpy.ops.view3d.camera_to_view_selected(view3d_context) 61 | 62 | 63 | def turntable(options): 64 | center, radius = align_info() 65 | 66 | create_camera_rig() 67 | 68 | bpy.data.objects['elmyra_camera_rig_camera'].data.clip_start = 0.01 69 | bpy.data.objects['elmyra_camera_rig_camera'].data.clip_end = radius * 9 70 | bpy.context.scene.camera = bpy.data.objects['elmyra_camera_rig_camera'] 71 | 72 | bpy.data.objects['elmyra_camera_rig_target'].location = center 73 | bpy.data.objects['elmyra_camera_rig_mount'].location = (center.x, center.y, center.z + radius * 1.4) 74 | bpy.data.objects['elmyra_camera_rig_mount'].scale = (radius * 3, radius * 3, radius * 3) 75 | 76 | bpy.context.scene.frame_current = 1 77 | bpy.data.objects['elmyra_camera_rig_camera'].constraints['elmyra_follow_mount'].offset = 0 78 | bpy.data.objects['elmyra_camera_rig_camera'].constraints['elmyra_follow_mount'].keyframe_insert(data_path='offset') 79 | 80 | bpy.context.scene.frame_current = bpy.context.scene.frame_end 81 | bpy.data.objects['elmyra_camera_rig_camera'].constraints['elmyra_follow_mount'].offset = 100 82 | bpy.data.objects['elmyra_camera_rig_camera'].constraints['elmyra_follow_mount'].keyframe_insert(data_path='offset') 83 | 84 | for fc in bpy.data.objects['elmyra_camera_rig_camera'].animation_data.action.fcurves: 85 | fc.extrapolation = 'LINEAR' 86 | for kp in fc.keyframe_points: 87 | kp.interpolation = 'LINEAR' 88 | 89 | 90 | def helix(options): 91 | center, radius = align_info() 92 | helix_scale = (radius * 3, radius * 3, radius * 3) 93 | 94 | create_camera_rig() 95 | 96 | bpy.data.objects['elmyra_camera_rig_camera'].data.clip_start = 0.01 97 | bpy.data.objects['elmyra_camera_rig_camera'].data.clip_end = radius * 9 98 | bpy.context.scene.camera = bpy.data.objects['elmyra_camera_rig_camera'] 99 | 100 | bpy.data.objects['elmyra_camera_rig_target'].location = center 101 | bpy.data.objects['elmyra_camera_rig_mount'].scale = helix_scale 102 | 103 | low_camera = (center.x, center.y, center.z - radius * 2) 104 | high_camera = (center.x, center.y, center.z + radius * 2) 105 | 106 | # Keyframe at the beginning - lowest point, follow circle 0% 107 | bpy.context.scene.frame_current = 1 108 | bpy.data.objects['elmyra_camera_rig_mount'].location = low_camera 109 | bpy.data.objects['elmyra_camera_rig_mount'].keyframe_insert(data_path='location') 110 | bpy.data.objects['elmyra_camera_rig_camera'].constraints['elmyra_follow_mount'].offset = 0 111 | bpy.data.objects['elmyra_camera_rig_camera'].constraints['elmyra_follow_mount'].keyframe_insert(data_path='offset') 112 | 113 | # Keyframe in the middle - highest point, follow circle 100% 114 | bpy.context.scene.frame_current = bpy.context.scene.frame_end / 2 115 | bpy.data.objects['elmyra_camera_rig_mount'].location = high_camera 116 | bpy.data.objects['elmyra_camera_rig_mount'].keyframe_insert(data_path='location') 117 | 118 | # Keyframe at the end - lowest point, follow circle 200% 119 | bpy.context.scene.frame_current = bpy.context.scene.frame_end 120 | bpy.data.objects['elmyra_camera_rig_mount'].location = low_camera 121 | bpy.data.objects['elmyra_camera_rig_mount'].keyframe_insert(data_path='location') 122 | bpy.data.objects['elmyra_camera_rig_camera'].constraints['elmyra_follow_mount'].offset = 200 123 | bpy.data.objects['elmyra_camera_rig_camera'].constraints['elmyra_follow_mount'].keyframe_insert(data_path='offset') 124 | 125 | # Make only the follow circle animation progression perfectly linear 126 | for fc in bpy.data.objects['elmyra_camera_rig_camera'].animation_data.action.fcurves: 127 | fc.extrapolation = 'LINEAR' 128 | for kp in fc.keyframe_points: 129 | kp.interpolation = 'LINEAR' 130 | 131 | 132 | def setup(options): 133 | if options.camera_type == 'fixed': 134 | fixed(options) 135 | elif options.camera_type == 'turntable': 136 | turntable(options) 137 | else: # options.camera_type == 'helix' 138 | helix(options) 139 | -------------------------------------------------------------------------------- /src/python/lib/common.py: -------------------------------------------------------------------------------- 1 | '''Utility methods to ensure dependencies, setup defaults, remove, append ...''' 2 | 3 | from os import path 4 | import json 5 | 6 | import bpy 7 | from addon_utils import check 8 | 9 | from lib.context import LIBRARY_DIR, UPLOAD_DIR 10 | 11 | 12 | def append_from_library(blend, directory, item): 13 | resource_path = path.join(LIBRARY_DIR, f"{blend}.blend", directory, '') 14 | 15 | bpy.ops.wm.append(filepath=resource_path, 16 | directory=resource_path, 17 | filename=item, 18 | autoselect=False) 19 | 20 | 21 | def empty_scene(): 22 | bpy.ops.object.select_all(action='DESELECT') 23 | 24 | for obj in bpy.context.scene.objects: 25 | obj.select_set(True) 26 | bpy.ops.object.delete() 27 | 28 | 29 | def ensure_addons(): 30 | # TODO: Persist addons being enabled as user settings (otherwise overhead!) 31 | 32 | is_enabled, is_loaded = check('render_freestyle_svg') 33 | if not is_enabled: 34 | bpy.ops.preferences.addon_enable(module='render_freestyle_svg') 35 | 36 | 37 | def get_view3d_context(): 38 | for area in bpy.context.screen.areas: 39 | if area.type == 'VIEW_3D': 40 | context = bpy.context.copy() 41 | context['area'] = area 42 | 43 | return context 44 | 45 | 46 | def remove_object(name): 47 | bpy.ops.object.select_all(action='DESELECT') 48 | bpy.data.objects[name].select_set(True) 49 | bpy.ops.object.delete() 50 | 51 | 52 | def setup_scene_defaults(): 53 | bpy.context.scene.render.engine = 'CYCLES' 54 | 55 | bpy.context.scene.cycles.max_bounces = 2 56 | bpy.context.scene.cycles.preview_samples = 12 57 | 58 | bpy.context.scene.render.image_settings.file_format = 'PNG' 59 | bpy.context.scene.render.image_settings.color_mode = 'RGBA' 60 | bpy.context.scene.render.image_settings.compression = 0 61 | bpy.context.scene.render.film_transparent = True 62 | bpy.context.scene.use_nodes = True 63 | 64 | for mat in bpy.data.materials: 65 | mat.use_nodes = True 66 | 67 | 68 | def open_upload(id): 69 | scene_path = path.join(UPLOAD_DIR, f"{id}.blend") 70 | 71 | bpy.ops.wm.open_mainfile(filepath=scene_path) 72 | -------------------------------------------------------------------------------- /src/python/lib/context.py: -------------------------------------------------------------------------------- 1 | from os import environ, path 2 | 3 | DATA_DIR = environ['ELMYRA_DATA_DIR'] 4 | FFMPEG_EXECUTABLE = environ['ELMYRA_FFMPEG_EXECUTABLE'] 5 | RUNTIME_DIR = environ['ELMYRA_RUNTIME_DIR'] 6 | 7 | IMPORTS_DIR = path.join(DATA_DIR, 'imports') 8 | LIBRARY_DIR = path.join(RUNTIME_DIR, 'lib/elmyra') 9 | UPLOAD_DIR = path.join(DATA_DIR, 'upload') 10 | VISUALIZATIONS_DIR = path.join(DATA_DIR, 'visualizations') 11 | -------------------------------------------------------------------------------- /src/python/lib/export.py: -------------------------------------------------------------------------------- 1 | '''Methods for exporting to different formats''' 2 | 3 | import bpy 4 | 5 | from datetime import datetime 6 | from glob import glob 7 | from os import path 8 | from shutil import copy 9 | from time import time 10 | from zipfile import ZipFile 11 | 12 | import subprocess 13 | 14 | from lib import meta 15 | from lib.context import FFMPEG_EXECUTABLE 16 | 17 | 18 | def export_still(): 19 | export_directory = bpy.path.abspath("//") 20 | image_directory = path.join(export_directory, "rendered_frames") 21 | pixel_image = glob(path.join(image_directory, "*.png"))[0] 22 | 23 | ffmpeg_input_options = [ 24 | FFMPEG_EXECUTABLE, 25 | "-y", 26 | "-i", pixel_image 27 | ] 28 | 29 | export_png(ffmpeg_input_options, export_directory) 30 | export_jpg(ffmpeg_input_options, export_directory) 31 | export_svg(image_directory, export_directory) 32 | 33 | 34 | def export_jpg(ffmpeg_input_options, export_directory): 35 | meta.write({ "processing": "Exporting JPG" }) 36 | benchmark = time() 37 | 38 | export_file = path.join(export_directory, "exported.jpg") 39 | ffmpeg_call = ffmpeg_input_options + [export_file] 40 | 41 | subprocess.run(ffmpeg_call) 42 | 43 | filesize = path.getsize(export_file) 44 | meta.write({ 45 | "processing": None, 46 | "jpg": { 47 | "filePath": "exported.jpg", 48 | "exported": datetime.now().isoformat(), 49 | "processingTime": time() - benchmark, 50 | "fileSize": filesize 51 | } 52 | }) 53 | 54 | 55 | def export_png(ffmpeg_input_options, export_directory): 56 | meta.write({ "processing": "Exporting PNG" }) 57 | benchmark = time() 58 | 59 | export_file = path.join(export_directory, "exported.png") 60 | ffmpeg_call = ffmpeg_input_options + [export_file] 61 | 62 | subprocess.run(ffmpeg_call) 63 | 64 | filesize = path.getsize(export_file) 65 | meta.write({ 66 | "processing": None, 67 | "png": { 68 | "filePath": "exported.png", 69 | "exported": datetime.now().isoformat(), 70 | "processingTime": time() - benchmark, 71 | "fileSize": filesize 72 | } 73 | }) 74 | 75 | 76 | def export_svg(image_directory, export_directory): 77 | vector_input_files = glob(path.join(image_directory, "*.svg")) 78 | 79 | if len(vector_input_files) > 0: 80 | meta.write({ "processing": "Exporting SVG" }) 81 | benchmark = time() 82 | 83 | export_file = path.join(export_directory, "exported.svg") 84 | 85 | copy(vector_input_files[0], export_file) 86 | 87 | filesize = path.getsize(export_file) 88 | meta.write({ 89 | "processing": None, 90 | "svg": { 91 | "filePath": "exported.svg", 92 | "exported": datetime.now().isoformat(), 93 | "processingTime": time() - benchmark, 94 | "fileSize": filesize 95 | } 96 | }) 97 | 98 | 99 | def export_animation(): 100 | export_directory = bpy.path.abspath("//") 101 | image_directory = path.join(export_directory, "rendered_frames") 102 | rendered_frames = sorted(glob(path.join(image_directory, "*.png"))) 103 | concat_file = path.join(export_directory, "export.concat").replace("\\", "/") # POSIX style paths required on all platforms 104 | font_file = path.join(path.dirname(__file__), 'lib', 'elmyra', 'oxygen-mono.ttf').replace("\\", "/") # POSIX style paths required on all platforms 105 | frame_duration = 1.0/24.0 106 | 107 | with open(concat_file, "w") as file: 108 | file.write("ffconcat version 1.0\n\n") 109 | 110 | for index, frame in enumerate(rendered_frames): 111 | filepath = path.join("rendered_frames", path.basename(frame)).replace("\\", "/") # POSIX style paths required on all platforms 112 | 113 | # Use the first available frame from the start of the animation 114 | if index == 0: 115 | number = bpy.context.scene.frame_start 116 | else: 117 | number = int(path.basename(frame).split(".")[0]) 118 | 119 | # Expand the last available frame to the end of the animation 120 | if index == len(rendered_frames) - 1: 121 | next_number = bpy.context.scene.frame_end + 1 122 | else: 123 | next_frame = rendered_frames[index + 1] 124 | next_number = int(path.basename(next_frame).split(".")[0]) 125 | 126 | # Write out the number of frames to interpolate (or exactly one) 127 | for _ in range(next_number - number): 128 | file.write(f"file {filepath}\n") 129 | file.write(f"duration {frame_duration}\n") 130 | 131 | size_string = f"{bpy.context.scene.render.resolution_x}x{bpy.context.scene.render.resolution_y}" 132 | 133 | filter_string = ("color=c=black:s=" + size_string + " [black];" 134 | "[black][0:v] overlay=shortest=1") 135 | 136 | # Draw a 'PREVIEW HH:MM:SS:MS' overlay if there are missing frames 137 | # This serves 90% to take away the illusion of laggy video rendering 138 | # (not in there because the timestamp or preview information is so important) 139 | if len(rendered_frames) < bpy.context.scene.frame_end - bpy.context.scene.frame_start: 140 | filter_string += (", drawtext=fontcolor=white:" 141 | "fontfile=" + font_file + ":" 142 | "fontsize=64:" 143 | "text='PREVIEW %{pts\:hms}':" 144 | "x=(w-tw)/2:" 145 | "y=h-(2*lh):" 146 | "box=1:" 147 | "boxcolor=0x00000000@1") 148 | 149 | export_mp4(concat_file, filter_string, export_directory) 150 | export_ogv(concat_file, filter_string, export_directory) 151 | export_webm(concat_file, filter_string, export_directory) 152 | export_gif(concat_file, filter_string, export_directory) 153 | export_png_sequence(image_directory, export_directory) 154 | export_svg_sequence(image_directory, export_directory) 155 | 156 | 157 | def export_mp4(concat_file, filter_string, export_directory): 158 | meta.write({ "processing": "Exporting MP4" }) 159 | benchmark = time() 160 | 161 | export_file = path.join(export_directory, "exported.mp4") 162 | 163 | subprocess.run([ 164 | FFMPEG_EXECUTABLE, 165 | "-y", 166 | "-f", "concat", 167 | "-i", concat_file, 168 | "-filter_complex", filter_string, 169 | "-c:v", "libx264", 170 | "-preset", "slow", 171 | "-crf", "4", 172 | export_file 173 | ]) 174 | 175 | filesize = path.getsize(export_file) 176 | meta.write({ 177 | "processing": None, 178 | "mp4": { 179 | "filePath": "exported.mp4", 180 | "exported": datetime.now().isoformat(), 181 | "processingTime": time() - benchmark, 182 | "fileSize": filesize 183 | } 184 | }) 185 | 186 | 187 | def export_ogv(concat_file, filter_string, export_directory): 188 | meta.write({ "processing": "Exporting OGV" }) 189 | benchmark = time() 190 | 191 | export_file = path.join(export_directory, "exported.ogv") 192 | 193 | subprocess.run([ 194 | FFMPEG_EXECUTABLE, 195 | "-y", 196 | "-f", "concat", 197 | "-i", concat_file, 198 | "-filter_complex", filter_string, 199 | "-c:v", "libtheora", 200 | "-qscale:v", "10", 201 | export_file 202 | ]) 203 | 204 | filesize = path.getsize(export_file) 205 | meta.write({ 206 | "processing": None, 207 | "ogv": { 208 | "filePath": "exported.ogv", 209 | "exported": datetime.now().isoformat(), 210 | "processingTime": time() - benchmark, 211 | "fileSize": filesize 212 | } 213 | }) 214 | 215 | 216 | def export_webm(concat_file, filter_string, export_directory): 217 | meta.write({ "processing": "Exporting WEBM" }) 218 | benchmark = time() 219 | 220 | export_file = path.join(export_directory, "exported.webm") 221 | 222 | subprocess.run([ 223 | FFMPEG_EXECUTABLE, 224 | "-y", 225 | "-f", "concat", 226 | "-i", concat_file, 227 | "-filter_complex", filter_string, 228 | "-c:v", "libvpx-vp9", 229 | "-crf", "4", 230 | "-speed", "1", 231 | "-b:v", "32M", 232 | export_file 233 | ]) 234 | 235 | filesize = path.getsize(export_file) 236 | meta.write({ 237 | "processing": None, 238 | "webm": { 239 | "filePath": "exported.webm", 240 | "exported": datetime.now().isoformat(), 241 | "processingTime": time() - benchmark, 242 | "fileSize": filesize 243 | } 244 | }) 245 | 246 | 247 | def export_gif(concat_file, filter_string, export_directory): 248 | """Export a GIF from the input frames, scaled down to 720p""" 249 | 250 | meta.write({ "processing": "Exporting GIF" }) 251 | benchmark = time() 252 | 253 | # GIF encoding technique taken from 254 | # http://blog.pkh.me/p/21-high-quality-gif-with-ffmpeg.html 255 | 256 | # TODO: Should we force-scale GIF for any reason (decision) 257 | # filter_string += ", scale=720:-1:flags=lanczos" 258 | 259 | palette_file = path.join(export_directory, "palette.png") 260 | subprocess.run([ 261 | FFMPEG_EXECUTABLE, 262 | "-y", 263 | "-f", "concat", 264 | "-i", concat_file, 265 | "-filter_complex", filter_string + ", palettegen", 266 | palette_file 267 | ]) 268 | 269 | export_file = path.join(export_directory, "exported.gif") 270 | subprocess.run([ 271 | FFMPEG_EXECUTABLE, 272 | "-y", 273 | "-f", "concat", 274 | "-i", concat_file, 275 | "-i", palette_file, 276 | "-filter_complex", filter_string + " [comp]; [comp][1:v] paletteuse", 277 | export_file 278 | ]) 279 | 280 | filesize = path.getsize(export_file) 281 | meta.write({ 282 | "processing": None, 283 | "gif": { 284 | "filePath": "exported.gif", 285 | "exported": datetime.now().isoformat(), 286 | "processingTime": time() - benchmark, 287 | "fileSize": filesize 288 | } 289 | }) 290 | 291 | 292 | def export_png_sequence(image_directory, export_directory): 293 | """Export all input frames as PNGs inside a ZIP""" 294 | 295 | meta.write({ "processing": "Exporting PNG Sequence" }) 296 | benchmark = time() 297 | 298 | raster_input_files = glob(path.join(image_directory, "*.png")) 299 | export_file = path.join(export_directory, "exported.png.zip") 300 | 301 | zip_file = ZipFile(export_file, 'w') 302 | 303 | for frame in raster_input_files: 304 | zip_file.write(frame, path.basename(frame)) 305 | 306 | filesize = path.getsize(export_file) 307 | meta.write({ 308 | "processing": None, 309 | "png.zip": { 310 | "filePath": "exported.png.zip", 311 | "exported": datetime.now().isoformat(), 312 | "processingTime": time() - benchmark, 313 | "fileSize": filesize 314 | } 315 | }) 316 | 317 | 318 | def export_svg_sequence(image_directory, export_directory): 319 | vector_input_files = glob(path.join(image_directory, "*.svg")) 320 | 321 | if len(vector_input_files) > 0: 322 | meta.write({ "processing": "Exporting SVG Sequence" }) 323 | benchmark = time() 324 | 325 | export_file = path.join(export_directory, "exported.svg.zip") 326 | 327 | zip_file = ZipFile(export_file, 'w') 328 | 329 | for frame in vector_input_files: 330 | zip_file.write(frame, path.basename(frame)) 331 | 332 | filesize = path.getsize(export_file) 333 | meta.write({ 334 | "processing": None, 335 | "svg.zip": { 336 | "filePath": "exported.svg.zip", 337 | "exported": datetime.now().isoformat(), 338 | "processingTime": time() - benchmark, 339 | "fileSize": filesize 340 | } 341 | }) 342 | 343 | 344 | def export(): 345 | if bpy.context.scene.frame_end > bpy.context.scene.frame_start: 346 | export_animation() 347 | else: 348 | export_still() 349 | -------------------------------------------------------------------------------- /src/python/lib/media.py: -------------------------------------------------------------------------------- 1 | """Methods to set media related (resolution, length) scene properties""" 2 | 3 | 4 | import bpy 5 | 6 | 7 | def setup(animated, width, height, length): 8 | """ 9 | Sets up the type, resolution and length of the currently open scene 10 | 11 | The render resolution of the scene is set, and additionally ... 12 | 13 | ... for stills, sets the length of the scene to exactly 1 frame. 14 | 15 | ... for animations, enables animated seed for cycles, sets the fps to 16 | 24 and the fps_base to 1. 17 | """ 18 | 19 | bpy.context.scene.render.resolution_percentage = 100 20 | bpy.context.scene.render.resolution_x = int(width) 21 | bpy.context.scene.render.resolution_y = int(height) 22 | 23 | if not animated: 24 | bpy.context.scene.frame_end = 1 25 | else: 26 | bpy.context.scene.cycles.use_animated_seed = True 27 | bpy.context.scene.frame_end = length * 24 28 | bpy.context.scene.render.fps = 24 29 | 30 | # different for weird framerates (23.9243924 stuffies you know) 31 | bpy.context.scene.render.fps_base = 1 32 | -------------------------------------------------------------------------------- /src/python/lib/meta.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import json 3 | 4 | from os import path 5 | 6 | 7 | def get(): 8 | blend_filepath = bpy.path.abspath("//") 9 | meta_filepath = path.join(blend_filepath, "meta.json") 10 | 11 | if path.exists(meta_filepath): 12 | with open(meta_filepath) as file: 13 | return json.loads(file.read()) 14 | else: 15 | return {} 16 | 17 | 18 | def read(version_dir): 19 | meta_file = path.join(version_dir, 'meta.json') 20 | 21 | if not path.exists(meta_file): 22 | return {} 23 | 24 | with open(meta_file) as file: 25 | return json.loads(file.read()) 26 | 27 | def write(attributes): 28 | blend_path = bpy.path.abspath("//") 29 | meta_path = path.join(blend_path, "meta.json") 30 | 31 | meta = {} 32 | 33 | if path.exists(meta_path): 34 | with open(meta_path) as file: 35 | meta = json.loads(file.read()) 36 | 37 | meta.update(attributes) 38 | 39 | with open(meta_path, "w") as file: 40 | file.write(json.dumps(meta, indent=2)) 41 | 42 | 43 | def write_media_info(): 44 | if bpy.context.scene.frame_end > bpy.context.scene.frame_start: 45 | num_frames = bpy.context.scene.frame_end - bpy.context.scene.frame_start 46 | media_length = round(num_frames / bpy.context.scene.render.fps) 47 | media_animated = True 48 | else: 49 | media_length = 0 50 | media_animated = False 51 | 52 | 53 | write({ 54 | "mediaWidth": bpy.context.scene.render.resolution_x, 55 | "mediaHeight": bpy.context.scene.render.resolution_y, 56 | "mediaLength": media_length, 57 | "mediaAnimated": media_animated, 58 | "mediaFps": bpy.context.scene.render.fps, 59 | "mediaFrameCount": bpy.context.scene.frame_end 60 | }) 61 | -------------------------------------------------------------------------------- /src/python/lib/modifier.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | from lib.common import append_from_library 4 | 5 | 6 | def section(options): 7 | # Avoid transparency glitches (section is based on transparency) 8 | # Might be still too little for some meshes 9 | # (encountered models which needed 42 bounces ...) 10 | bpy.context.scene.cycles.transparent_max_bounces = 16 11 | 12 | axis = options.modifier_section_axis 13 | 14 | append_from_library('section', 'NodeTree', 'section') 15 | section_node_group = bpy.data.node_groups['section'] 16 | 17 | for mat in bpy.data.materials: 18 | section_node_group_node = mat.node_tree.nodes.new('ShaderNodeGroup') 19 | section_node_group_node.node_tree = bpy.data.node_groups['section'] 20 | 21 | if options.modifier_type == 'animated-cross-section': 22 | bpy.context.scene.frame_current = 1 23 | section_node_group_node.inputs[axis].default_value = options.modifier_section_level_from 24 | section_node_group_node.inputs[axis].keyframe_insert('default_value') 25 | 26 | bpy.context.scene.frame_current = bpy.context.scene.frame_end 27 | section_node_group_node.inputs[axis].default_value = options.modifier_section_level_to 28 | section_node_group_node.inputs[axis].keyframe_insert('default_value') 29 | 30 | for fc in section_node_group_node.inputs[axis].id_data.animation_data.action.fcurves: 31 | fc.extrapolation = 'LINEAR' 32 | for kp in fc.keyframe_points: 33 | kp.interpolation = 'LINEAR' 34 | else: 35 | section_node_group_node.inputs[axis].default_value = options.modifier_section_level 36 | 37 | surface_shader = None 38 | for node in mat.node_tree.nodes: 39 | if node.type == 'OUTPUT_MATERIAL': 40 | output_material_node = node 41 | for input in node.inputs: 42 | for link in input.links: 43 | surface_shader = link.from_node 44 | 45 | mat.node_tree.links.new(surface_shader.outputs[0], 46 | section_node_group_node.inputs['Shader']) 47 | 48 | mat.node_tree.links.new(section_node_group_node.outputs['Shader'], 49 | output_material_node.inputs['Surface']) 50 | 51 | 52 | def setup(options): 53 | if options.modifier_type == 'none': 54 | pass 55 | elif options.modifier_type in ('cross-section', 'animated-cross-section'): 56 | section(options) 57 | -------------------------------------------------------------------------------- /src/python/lib/render.py: -------------------------------------------------------------------------------- 1 | """Offers methods to render frames for a given time with a given device""" 2 | 3 | import bpy 4 | 5 | from glob import glob 6 | from datetime import datetime 7 | from os import makedirs, path, remove, rename 8 | from time import time 9 | 10 | import subprocess 11 | 12 | from lib import meta 13 | from lib.context import FFMPEG_EXECUTABLE 14 | 15 | 16 | SAMPLES_INITIAL = 8 17 | SAMPLES_MULTIPLIER = 0.5 18 | SAMPLES_CAP = 3200 19 | 20 | QUALITY_PREVIEW = 32 21 | QUALITY_PRODUCTION = 320 22 | 23 | def render_frame(render_directory, 24 | frame, 25 | existing_samples, 26 | additional_samples, 27 | existing_frame=None): 28 | begin_time = time() 29 | 30 | bpy.context.scene.frame_current = frame 31 | bpy.context.scene.cycles.samples = additional_samples 32 | 33 | # Enable SVG export when using Freestyle 34 | if bpy.context.scene.render.use_freestyle: 35 | bpy.context.scene.svg_export.use_svg_export = True 36 | 37 | cache_filename = ".render-cache.png" 38 | cache_filepath = path.join(render_directory, cache_filename) 39 | 40 | bpy.context.scene.render.filepath = cache_filepath 41 | bpy.ops.render.render(write_still=True) 42 | 43 | if existing_frame: 44 | alpha = float(existing_samples) / float(existing_samples + additional_samples) 45 | 46 | result_samples = existing_samples + additional_samples 47 | result_filename = "{0:06}.{1}.png".format(frame, result_samples) 48 | result_filepath = path.join(render_directory, result_filename) 49 | 50 | ffmpeg_call = [ 51 | FFMPEG_EXECUTABLE, 52 | "-y", 53 | "-i", existing_frame, 54 | "-i", cache_filepath, 55 | "-filter_complex", 56 | f"[1:v][0:v]blend=all_expr='A*{alpha}+B*{1 - alpha}'", 57 | result_filepath 58 | ] 59 | 60 | subprocess.run(ffmpeg_call) 61 | 62 | remove(existing_frame) 63 | remove(cache_filepath) 64 | 65 | else: 66 | result_filename = "{0:06}.{1}.png".format(frame, additional_samples) 67 | result_filepath = path.join(render_directory, result_filename) 68 | 69 | rename(cache_filepath, result_filepath) 70 | 71 | if bpy.context.scene.render.use_freestyle: 72 | svg_old_filepath = path.join(render_directory, 73 | "{0}{1:04}.svg".format(cache_filename, frame)) 74 | svg_new_filepath = path.join(render_directory, 75 | "{0:06}.svg".format(frame)) 76 | 77 | if existing_frame: 78 | remove(svg_new_filepath) 79 | 80 | rename(svg_old_filepath, svg_new_filepath) 81 | 82 | # Thumbnail creation 83 | 84 | thumbnail_filepath = path.join(render_directory, "..", "thumbnail.png") 85 | subprocess.run([ 86 | FFMPEG_EXECUTABLE, 87 | "-y", 88 | "-f", "image2", 89 | "-i", result_filepath, 90 | "-vf", "scale=480:270:force_original_aspect_ratio=decrease", 91 | thumbnail_filepath 92 | ]) 93 | 94 | meta.write({ 95 | "renderDevice": bpy.context.scene.cycles.device, 96 | "lastRenderedFrame": frame, 97 | "lastRenderDuration": time() - begin_time, 98 | "lastRender": datetime.now().isoformat(), 99 | "lastRenderedSamples": additional_samples 100 | }) 101 | 102 | 103 | def render(target_time, device): 104 | begin_time = time() 105 | 106 | bpy.context.scene.cycles.seed = int(begin_time) # Imagestacking random seed 107 | bpy.context.scene.cycles.device = device 108 | 109 | render_directory = path.join(bpy.path.abspath("//"), "rendered_frames") 110 | 111 | if not path.exists(render_directory): 112 | makedirs(render_directory) 113 | 114 | first = bpy.context.scene.frame_start 115 | last = bpy.context.scene.frame_end 116 | total_frames = last - first + 1 117 | 118 | rendered_frames = sorted(glob(path.join(render_directory, "*.png"))) 119 | requested_frames = [] 120 | 121 | if len(rendered_frames) < total_frames: 122 | meta.write({ "processing": "Rendering missing frames" }) 123 | 124 | # Build the initial list of frames based on a binary split pattern: 125 | # 126 | # | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | (10 example frame numbers) 127 | # ----------------------------------------- 128 | # | | | | | 1 | | | | | | 129 | # | | | 2 | | | | | 3 | | | (split pattern and 130 | # | 4 | | | 5 | | 6 | | | 7 | | render order) 131 | # | | 8 | | | | | 9 | | |10 | 132 | 133 | all_frames = range(first, last + 1) 134 | 135 | numbers = [int(path.basename(r).split(".")[0]) for r in rendered_frames] 136 | 137 | all_ranges = [] 138 | buffer_range = [] 139 | 140 | for frame in all_frames: 141 | if frame not in numbers: 142 | buffer_range.append(frame) 143 | elif len(buffer_range) > 0: 144 | all_ranges.append(buffer_range) 145 | buffer_range = [] 146 | 147 | if len(buffer_range) > 0: 148 | all_ranges.append(buffer_range) 149 | buffer_range = [] 150 | 151 | while len(all_ranges) > 0: 152 | largest_range = max(all_ranges, key=lambda r: [len(r), -min(r)]) 153 | all_ranges.remove(largest_range) 154 | split_index = len(largest_range) // 2 155 | 156 | for index, frame in enumerate(largest_range): 157 | if index == split_index: 158 | requested_frames.append({ 159 | "number": frame, 160 | "available_samples": 0, 161 | "requested_samples": SAMPLES_INITIAL, 162 | "available_frame": None 163 | }) 164 | 165 | if len(buffer_range) > 0: 166 | all_ranges.append(buffer_range) 167 | buffer_range = [] 168 | else: 169 | buffer_range.append(frame) 170 | 171 | if len(buffer_range) > 0: 172 | all_ranges.append(buffer_range) 173 | buffer_range = [] 174 | 175 | else: 176 | samples = [int(path.basename(r).split(".")[1]) for r in rendered_frames] 177 | min_samples = min(samples) 178 | max_samples = max(samples) 179 | 180 | if min_samples < QUALITY_PREVIEW: 181 | render_quality = "draft" 182 | elif min_samples > QUALITY_PREVIEW: 183 | render_quality = "preview" 184 | elif min_samples > QUALITY_PRODUCTION: 185 | render_quality = "production" 186 | 187 | meta.write({ 188 | "processing": "Rendering more samples", 189 | "minimumSamples": min_samples, 190 | "renderQuality": render_quality 191 | }) 192 | 193 | if min_samples != max_samples: 194 | for frame in rendered_frames: 195 | frame_info = path.basename(frame).split(".") 196 | frame_number = int(frame_info[0]) 197 | frame_samples = int(frame_info[1]) 198 | 199 | if frame_samples < max_samples: 200 | requested_frames.append({ 201 | "number": frame_number, 202 | "available_samples": frame_samples, 203 | "requested_samples": max_samples - frame_samples, 204 | "available_frame": frame 205 | }) 206 | 207 | elif min_samples < SAMPLES_CAP: 208 | for frame in rendered_frames: 209 | frame_info = path.basename(frame).split(".") 210 | frame_number = int(frame_info[0]) 211 | 212 | requested_frames.append({ 213 | "number": frame_number, 214 | "available_samples": min_samples, 215 | "requested_samples": int(min_samples * SAMPLES_MULTIPLIER), 216 | "available_frame": frame 217 | }) 218 | 219 | for frame in requested_frames: 220 | render_frame(render_directory, 221 | frame["number"], 222 | frame["available_samples"], 223 | frame["requested_samples"], 224 | frame["available_frame"]) 225 | 226 | if time() - begin_time > target_time: 227 | break 228 | 229 | meta.write({ "processing": False }) 230 | -------------------------------------------------------------------------------- /src/python/lib/style.py: -------------------------------------------------------------------------------- 1 | """Methods to set style-related properties on the objects and scene""" 2 | 3 | import bpy 4 | 5 | from lib.common import append_from_library 6 | 7 | 8 | def setup_illustrated(): 9 | append_from_library("illustrated", "FreestyleLineStyle", "Contour") 10 | append_from_library("illustrated", "FreestyleLineStyle", "Details") 11 | append_from_library("illustrated", "World", "illustrated") 12 | 13 | bpy.context.scene.render.use_freestyle = True 14 | bpy.context.scene.svg_export.use_svg_export = True 15 | 16 | for obj in bpy.data.objects: 17 | if obj.type == 'MESH': 18 | obj.cycles_visibility.camera = False 19 | 20 | illustrated_world = bpy.data.worlds["illustrated"] 21 | bpy.context.scene.world = illustrated_world 22 | 23 | bpy.context.scene.cycles.film_transparent = False 24 | 25 | 26 | def setup_realistic(): 27 | append_from_library("realistic", "Material", "realistic") 28 | append_from_library("realistic", "World", "realistic") 29 | 30 | realistic_material = bpy.data.materials["realistic"] 31 | for obj in bpy.data.objects: 32 | if obj.type == 'MESH': 33 | obj.data.materials.append(realistic_material) 34 | 35 | realistic_world = bpy.data.worlds["realistic"] 36 | bpy.context.scene.world = realistic_world 37 | 38 | 39 | def setup(style_type): 40 | if style_type == "illustrated": 41 | setup_illustrated() 42 | else: # style_type == "realistic" 43 | setup_realistic() 44 | -------------------------------------------------------------------------------- /src/python/lib/update.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import hashlib 3 | import requests 4 | 5 | from os import makedirs, path, remove 6 | from shutil import copyfile 7 | 8 | from lib import meta 9 | from lib.common import append_from_library, remove_object, get_view3d_context 10 | from lib.context import DATA_DIR, IMPORTS_DIR 11 | 12 | 13 | def import_model(args): 14 | # TODO: Add a detailed error feedback path to the web interface (e.g. url 404ed etc.) 15 | 16 | upload_file = path.join(DATA_DIR, args.url) 17 | import_dir = path.join(IMPORTS_DIR, args.import_id) 18 | 19 | makedirs(import_dir) 20 | 21 | import_file = path.join(import_dir, f"source.{args.format}") 22 | import_preview = path.join(import_dir, "preview.obj") 23 | import_scene = path.join(import_dir, "imported.blend") 24 | 25 | 26 | # Copy or download the source file to the import directory 27 | if path.exists(upload_file): 28 | copyfile(upload_file, import_file) 29 | else: 30 | request = requests.get(args.url) 31 | 32 | if request.status_code == requests.codes.ok: 33 | with open(import_file, "wb") as file: 34 | file.write(request.content) 35 | else: 36 | return False 37 | 38 | # TODO: Look in detail at each format, add more, tweak, remove as necessary 39 | if args.format == "blend": 40 | # TODO: See .obj notes 41 | 42 | with bpy.data.libraries.load(import_file) as (data_from, data_to): 43 | data_to.objects = data_from.objects 44 | 45 | for obj in data_to.objects: 46 | if obj is not None and obj.type == "MESH": 47 | bpy.context.collection.objects.link(obj) 48 | bpy.context.view_layer.objects.active = obj 49 | obj.select_set(True) 50 | 51 | elif args.format == "stl": 52 | bpy.ops.import_mesh.stl(filepath=import_file) 53 | elif args.format == "ply": 54 | bpy.ops.import_mesh.ply(filepath=import_file) 55 | elif args.format == "3ds": 56 | bpy.ops.import_scene.autodesk_3ds(filepath=import_file) 57 | elif args.format == "fbx": 58 | bpy.ops.import_scene.fbx(filepath=import_file) 59 | elif args.format == "obj": 60 | bpy.ops.import_scene.obj(filepath=import_file) 61 | 62 | # TODO: After obj import everything imported is SELECTED but not ACTIVE 63 | # This is because in contrast to .stl import we import a scene, 64 | # not a single mesh/object, and thus the smoothing, modifier adding 65 | # that happens afterwards here, needs to be performed on all the 66 | # objects, or all objects need to be unified into one. 67 | # Also this confronts elmyra with a weakness in design: 68 | # Being centered around one object only ... 69 | # Needs thinking. 70 | elif args.format == 'dae': 71 | bpy.ops.wm.collada_import(filepath=import_file) 72 | else: 73 | return False 74 | 75 | bpy.ops.object.shade_smooth() 76 | bpy.ops.object.modifier_add(type='EDGE_SPLIT') 77 | 78 | bpy.context.active_object['elmyra-hash'] = get_hash_url(import_file) 79 | bpy.context.active_object['elmyra-url'] = args.url 80 | 81 | bpy.ops.wm.save_as_mainfile(filepath=import_scene) 82 | 83 | export_browser_preview(import_preview) 84 | 85 | return True 86 | 87 | 88 | def export_browser_preview(import_preview): 89 | # Place in center 90 | bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY') 91 | bpy.context.object.location = [0, 0, 0] 92 | 93 | # Normalize to size 1 94 | max_dimension = max(bpy.context.object.dimensions) 95 | bpy.context.object.scale[0] /= max_dimension 96 | bpy.context.object.scale[1] /= max_dimension 97 | bpy.context.object.scale[2] /= max_dimension 98 | bpy.ops.object.transform_apply(scale=True) 99 | 100 | face_count = len(bpy.context.object.data.polygons) 101 | if face_count > 64000: 102 | bpy.ops.object.modifier_add(type='DECIMATE') 103 | bpy.context.object.modifiers["Decimate"].ratio = 64000 / face_count 104 | bpy.ops.object.modifier_apply(apply_as='DATA', modifier="Decimate") 105 | 106 | # Append orientation widgets 107 | append_from_library("preview-widgets", "Object", "widget-flip-horizontally") 108 | append_from_library("preview-widgets", "Object", "widget-flip-vertically") 109 | append_from_library("preview-widgets", "Object", "widget-tilt") 110 | append_from_library("preview-widgets", "Object", "widget-turn") 111 | 112 | bpy.ops.object.select_all(action='SELECT') 113 | 114 | bpy.ops.export_scene.obj(filepath=import_preview, 115 | check_existing=False, 116 | use_materials=False, 117 | axis_forward="Y", 118 | axis_up="Z", 119 | use_triangles=True) 120 | 121 | 122 | def import_scene(import_id, 123 | orient_flip_horizontally, 124 | orient_flip_vertically, 125 | orient_rotate_x, 126 | orient_rotate_y, 127 | orient_rotate_z): 128 | # TODO: This imports also camera, lights, etc. ... 129 | # TODO: Conceptually this imports one object, the code is like if there 130 | # were many objects imported though, unclean, unclear. 131 | 132 | blend_file = path.join(IMPORTS_DIR, import_id, 'imported.blend') 133 | 134 | with bpy.data.libraries.load(blend_file) as (data_from, data_to): 135 | data_to.objects = data_from.objects 136 | 137 | for obj in data_to.objects: 138 | if obj is not None: 139 | bpy.context.collection.objects.link(obj) 140 | bpy.context.view_layer.objects.active = obj 141 | bpy.ops.object.mode_set(mode='EDIT') 142 | 143 | view3d_context = get_view3d_context() 144 | 145 | bpy.ops.mesh.select_all(view3d_context, action='SELECT') 146 | 147 | bpy.ops.transform.mirror(view3d_context, 148 | constraint_axis=(False, 149 | orient_flip_horizontally == 'true', 150 | orient_flip_vertically == 'true')) 151 | 152 | # We need to flip the normals if the mesh is mirrored 153 | # on one axis only (if mirrored on both we don't need to) 154 | if orient_flip_horizontally != orient_flip_vertically: 155 | bpy.ops.mesh.flip_normals(view3d_context) 156 | 157 | bpy.ops.transform.rotate(orient_axis='Z', value=orient_rotate_z) 158 | bpy.ops.transform.rotate(orient_axis='Y', value=orient_rotate_y) 159 | bpy.ops.transform.rotate(orient_axis='X', value=orient_rotate_x) 160 | 161 | bpy.ops.mesh.select_all(view3d_context, action='DESELECT') 162 | bpy.ops.object.mode_set(mode='OBJECT') 163 | 164 | 165 | def get_stl(url): 166 | if path.exists(url): 167 | with open(url, "rb") as file: 168 | return file.read() 169 | else: 170 | # Get binary file with requests library 171 | return requests.get(url).content 172 | 173 | 174 | def get_hash_url(url): 175 | with open(url, "rb") as file: 176 | data = file.read() 177 | hasher = hashlib.sha1() 178 | hasher.update(data) 179 | return hasher.hexdigest() 180 | 181 | 182 | def get_hash(data): 183 | hasher = hashlib.sha1() 184 | hasher.update(data) 185 | return hasher.hexdigest() 186 | 187 | 188 | def temp_write(data, data_hash): 189 | tmp_dirpath = path.join(path.dirname(__file__), 'tmp') 190 | filepath = path.join(tmp_dirpath, f".{data_hash}.stl") 191 | 192 | with open(filepath, "wb") as file: 193 | file.write(data) 194 | 195 | return filepath 196 | 197 | 198 | def update_object(obj): 199 | stl = get_stl(obj["elmyra-url"]) 200 | new_hash = get_hash(stl) 201 | 202 | if new_hash != obj["elmyra-hash"]: 203 | stl_filepath = temp_write(stl, new_hash) 204 | 205 | bpy.ops.import_mesh.stl(filepath=stl_filepath) 206 | bpy.ops.object.shade_smooth() 207 | 208 | remove(stl_filepath) 209 | 210 | obj_new_geometry = bpy.context.scene.objects.active 211 | update_geometry(obj, obj_new_geometry) 212 | remove_object(obj_new_geometry.name) 213 | 214 | obj["elmyra-hash"] = new_hash 215 | 216 | return True 217 | else: 218 | return False 219 | 220 | 221 | def update_geometry(obj, obj_new_geometry): 222 | 223 | # Transfer materials, modifiers with data links 224 | obj.select_set(True) 225 | obj_new_geometry.select_set(True) 226 | 227 | bpy.context.scene.objects.active = obj 228 | bpy.ops.object.make_links_data(type='MATERIAL') 229 | bpy.ops.object.make_links_data(type='MODIFIERS') 230 | 231 | # Transfer all sorts of stuff with the Transfer Mesh Data operator 232 | bpy.context.scene.objects.active = obj_new_geometry 233 | bpy.ops.object.data_transfer(data_type='SHARP_EDGE', use_object_transform=False) 234 | bpy.ops.object.data_transfer(data_type='CREASE', use_object_transform=True) 235 | bpy.ops.object.data_transfer(data_type='BEVEL_WEIGHT_EDGE', use_object_transform=False) 236 | bpy.ops.object.data_transfer(data_type='FREESTYLE_EDGE', use_object_transform=True) 237 | bpy.ops.object.data_transfer(data_type='VCOL', use_object_transform=False) 238 | bpy.ops.object.data_transfer(data_type='UV', loop_mapping='POLYINTERP_NEAREST', use_object_transform=True) 239 | bpy.ops.object.data_transfer(data_type='SMOOTH', use_object_transform=False) 240 | bpy.ops.object.data_transfer(data_type='VGROUP_WEIGHTS', use_object_transform=False, layers_select_src='ALL', layers_select_dst='NAME') 241 | 242 | obj.data = obj_new_geometry.data 243 | 244 | # TODO: Rewrite after data transfer modifer bugfix (decide between operator and modifier as well) 245 | # Pro modifier: User could manually tweak transfer after the fact ... 246 | # Transfer things with data transfer modifier 247 | 248 | # bpy.ops.object.select_all(action="DESELECT") 249 | # bpy.context.scene.objects.active = some_obj_rewrite this please 250 | 251 | # Transfer UV coordinates 252 | # bpy.ops.object.modifier_add(type='DATA_TRANSFER') 253 | # bpy.context.object.modifiers["DataTransfer"].object = old_obj 254 | # bpy.context.object.modifiers["DataTransfer"].use_object_transform = False 255 | # bpy.context.object.modifiers["DataTransfer"].use_loop_data = True 256 | # bpy.context.object.modifiers["DataTransfer"].loop_mapping = 'POLYINTERP_NEAREST' 257 | # bpy.context.object.modifiers["DataTransfer"].data_types_loops_uv = {'UV'} 258 | # bpy.ops.object.datalayout_transfer(modifier="DataTransfer") 259 | # bpy.ops.object.modifier_apply(apply_as='DATA', modifier="DataTransfer") 260 | # 261 | # # Transfer smoothing 262 | # bpy.ops.object.modifier_add(type='DATA_TRANSFER') 263 | # bpy.context.object.modifiers["DataTransfer"].object = old_obj 264 | # bpy.context.object.modifiers["DataTransfer"].use_object_transform = False 265 | # bpy.context.object.modifiers["DataTransfer"].use_poly_data = True 266 | # bpy.context.object.modifiers["DataTransfer"].data_types_polys = {'SMOOTH'} 267 | # bpy.ops.object.datalayout_transfer(modifier="DataTransfer") 268 | # bpy.ops.object.modifier_apply(apply_as='DATA', modifier="DataTransfer") 269 | # 270 | # # Transfer sharp edges 271 | # bpy.ops.object.modifier_add(type='DATA_TRANSFER') 272 | # bpy.context.object.modifiers["DataTransfer"].object = old_obj 273 | # bpy.context.object.modifiers["DataTransfer"].use_object_transform = False 274 | # bpy.context.object.modifiers["DataTransfer"].use_edge_data = True 275 | # bpy.context.object.modifiers["DataTransfer"].data_types_edges = {'SHARP_EDGE'} 276 | # bpy.ops.object.datalayout_transfer(modifier="DataTransfer") 277 | # bpy.ops.object.modifier_apply(apply_as='DATA', modifier="DataTransfer") 278 | 279 | 280 | # TODO: Transfer material ASSIGNMENT, split object 281 | # bpy.ops.mesh.uv_texture_add() 282 | # bpy.context.object.data.active_index = 0 283 | # bpy.context.object.data.uv_textures["MATERIAL_TRANSFER_666"].name = "MATERIAL_TRANSFER_666" 284 | # bpy.ops.object.editmode_toggle() 285 | 286 | 287 | 288 | def update_models(): 289 | meta.write({ 'processing': 'Updating models' }) 290 | 291 | updates_occurred = False 292 | 293 | # Update existing models 294 | for obj in bpy.data.objects: 295 | if obj.get('elmyra-url') is not None: 296 | if update_object(obj): 297 | updates_occurred = True 298 | 299 | meta.write({ 'processing': None }) 300 | 301 | return updates_occurred 302 | -------------------------------------------------------------------------------- /src/python/lib/version.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | from glob import glob 4 | from os import makedirs, path 5 | from time import strftime 6 | 7 | from lib.context import VISUALIZATIONS_DIR 8 | 9 | 10 | def latest_version_dir(visualization_dir): 11 | versions_glob = path.join(visualization_dir, '*') 12 | ascending_version_dirs = sorted(glob(versions_glob)) 13 | 14 | return ascending_version_dirs[-1] 15 | 16 | 17 | def open_latest(visualization_dir): 18 | version_dir = latest_version_dir(visualization_dir) 19 | blend_file = path.join(version_dir, 'scene.blend') 20 | 21 | if path.exists(blend_file): 22 | bpy.ops.wm.open_mainfile(filepath=blend_file) 23 | 24 | return True 25 | else: 26 | return False 27 | 28 | 29 | def save_new(id): 30 | version = strftime('%Y%m%dT%H%M') 31 | version_dir = path.join(VISUALIZATIONS_DIR, id, version) 32 | scene_file = path.join(version_dir, 'scene.blend') 33 | 34 | makedirs(version_dir) 35 | 36 | bpy.ops.file.pack_all() 37 | bpy.ops.wm.save_as_mainfile(filepath=scene_file) 38 | -------------------------------------------------------------------------------- /src/python/render.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Opens, renders and exports a visualization 3 | 4 | Optional arguments: 5 | id -- the visualization to render 6 | device -- the device to render on - 'CPU' or 'GPU' (default 'CPU') 7 | target_time -- the minimum time to render, in seconds (default 60) 8 | ''' 9 | 10 | 11 | import sys 12 | 13 | from argparse import ArgumentParser 14 | from os import chdir, path 15 | from time import sleep 16 | 17 | 18 | # Make this script's directory the current working directory (could be anything else) 19 | # and add it to sys.path (this script runs from blender context) 20 | elmyra_root = path.dirname(path.realpath(__file__)) 21 | chdir(elmyra_root) 22 | sys.path.append(elmyra_root) 23 | 24 | from lib import common, export, render, version 25 | from lib.context import VISUALIZATIONS_DIR 26 | 27 | def parse_custom_args(): 28 | parser = ArgumentParser(prog='Elmyra Render Params') 29 | parser.add_argument("--id", required=True) 30 | parser.add_argument('--device', default='CPU') 31 | parser.add_argument('--target-time', type=int, default=60) 32 | 33 | custom_args = sys.argv[sys.argv.index('--') + 1:] 34 | 35 | return parser.parse_args(custom_args) 36 | 37 | 38 | args = parse_custom_args() 39 | common.ensure_addons() 40 | 41 | if version.open_latest(path.join(VISUALIZATIONS_DIR, args.id)): 42 | render.render(args.target_time, args.device) 43 | export.export() 44 | -------------------------------------------------------------------------------- /src/python/update.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Updates the scene from a blendfile or by trying to update from the original URLs 3 | 4 | Custom arguments: 5 | id -- the visualization identifier (required) 6 | blend -- a path to a blendfile to replace the current scene with (optional) 7 | min_interval -- a minimum time to keep between updates, in seconds (optional) 8 | ''' 9 | 10 | 11 | import sys 12 | 13 | from argparse import ArgumentParser 14 | from os import chdir, path 15 | from time import time 16 | 17 | # Make this script's directory the current working directory (could be anything else) 18 | # and add it to sys.path (this script runs from blender context) 19 | elmyra_root = path.dirname(path.realpath(__file__)) 20 | chdir(elmyra_root) 21 | sys.path.append(elmyra_root) 22 | 23 | from lib import common, meta, update, version 24 | 25 | 26 | def parse_custom_args(): 27 | parser = ArgumentParser(prog='Elmyra Update Params') 28 | parser.add_argument('--id', required=True) 29 | parser.add_argument('--upload-id', default=None) 30 | parser.add_argument('--min-interval', type=int, default=None) 31 | 32 | custom_args = sys.argv[sys.argv.index('--') + 1:] 33 | 34 | return parser.parse_args(custom_args) 35 | 36 | 37 | args = parse_custom_args() 38 | 39 | 40 | common.ensure_addons() 41 | 42 | if args.upload_id: 43 | common.open_upload(args.upload_id) 44 | version.save_new(args.id) 45 | meta.write_media_info() 46 | else: 47 | run_updates = True 48 | 49 | if args.min_interval: 50 | meta = meta.get() 51 | if 'lastUpdate' in meta: 52 | run_updates = time() - meta['lastUpdate'] < args.min_interval 53 | 54 | if run_updates: 55 | version.open_latest(args.id) 56 | 57 | # TODO: Find problem: Why does it update although hash stayed the same? 58 | # (Happened on update form external sources manually) 59 | # (Note 06/03/2016 - not sure if still applies) 60 | 61 | if update.update_models(): 62 | version.save_new(args.id) 63 | meta.write_media_info() 64 | else: 65 | meta.write({ 'lastUpdate': time() }) 66 | -------------------------------------------------------------------------------- /src/rust/args.rs: -------------------------------------------------------------------------------- 1 | use clap::{arg_enum, Clap}; 2 | 3 | arg_enum! { 4 | #[derive(Debug)] 5 | pub enum RenderDevice { 6 | Cpu, 7 | Gpu 8 | } 9 | } 10 | 11 | impl RenderDevice { 12 | pub fn to_str(&self) -> &str { 13 | match self { 14 | Self::Cpu => "CPU", 15 | Self::Gpu => "GPU" 16 | } 17 | } 18 | } 19 | 20 | #[clap(version = env!("CARGO_PKG_VERSION"))] 21 | #[derive(Clap, Debug)] 22 | pub struct Args { 23 | 24 | /// Which address to listen on. 25 | #[clap(default_value = "0.0.0.0", long = "address", short = "a")] 26 | pub address: String, 27 | 28 | /// By default elmyra uses a bundled version of blender (strongly recommended), you can override it with this if you know what you're doing or if would like to play and learn. 29 | #[clap(long = "blender-path")] 30 | pub blender_path: Option, 31 | 32 | /// The directory to store visualization files and rendered material in, by default elmyra's runtime directory (= the one where the executable and bundled resources are located) is used. 33 | #[clap(long = "data-dir")] 34 | pub data_dir: Option, 35 | 36 | /// By default elmyra runs a renderer process in the background, this option disables it. 37 | #[clap(long = "disable-rendering")] 38 | pub disable_rendering: bool, 39 | 40 | /// By default elmyra uses a bundled version of ffmpeg (strongly recommended), you can override it with this if you know what you're doing or if you would like to play and learn. 41 | #[clap(long = "ffmpeg-path")] 42 | pub ffmpeg_path: Option, 43 | 44 | /// Which port to listen on. 45 | #[clap(default_value = "8080", short = "p", long = "port")] 46 | pub port: u16, 47 | 48 | /// Customize which computing device the renderer should use, that is: CPU or GPU 49 | #[clap(case_insensitive = true, default_value = "CPU", long = "render-device", possible_values = &RenderDevice::variants())] 50 | pub render_device: RenderDevice, 51 | 52 | /// Customize how many seconds the renderer should spend on each visualization (they are rendered in turns) - note that this is a mininum suggestion: if a single rendering action takes longer than the target time, the renderer only moves to the next visualization when the action has completed. 53 | #[clap(default_value = "60", long = "render-target-time")] 54 | pub render_target_time: usize 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/rust/build.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] extern crate serde_derive; 2 | 3 | use std::fs; 4 | 5 | #[derive(Deserialize)] 6 | struct Paths { 7 | pub blender: String, 8 | pub ffmpeg: String 9 | } 10 | 11 | #[cfg(target_os = "linux")] 12 | pub const PATHS_JSON: &str = "lib/linux/paths.json"; 13 | 14 | #[cfg(target_os = "macos")] 15 | pub const PATHS_JSON: &str = "lib/macos/paths.json"; 16 | 17 | #[cfg(target_os = "windows")] 18 | pub const PATHS_JSON: &str = "lib/windows/paths.json"; 19 | 20 | 21 | fn main() { 22 | let paths_json = fs::read_to_string(PATHS_JSON).unwrap(); 23 | let paths: Paths = serde_json::from_str(&paths_json).unwrap(); 24 | 25 | let library_rs = vec![ 26 | format!("pub const BLENDER: &str = \"{}\";", paths.blender), 27 | format!("pub const FFMPEG: &str = \"{}\";", paths.ffmpeg) 28 | ].join("\n"); 29 | 30 | fs::write("src/rust/library.rs", library_rs).unwrap(); 31 | } 32 | -------------------------------------------------------------------------------- /src/rust/context.rs: -------------------------------------------------------------------------------- 1 | //! Determines the data and executable directories used to read and write resources 2 | //! based on the process environment context and/or optional command line arguments. 3 | //! Also creates necessary directories within the data directory if they don't exist. 4 | 5 | use std::{ 6 | env, 7 | fs, 8 | path::PathBuf, 9 | process::Command 10 | }; 11 | 12 | use crate::args::Args; 13 | use crate::library; 14 | 15 | #[derive(Clone)] 16 | pub struct Context { 17 | blender_executable: PathBuf, 18 | pub data_dir: PathBuf, 19 | ffmpeg_executable: PathBuf, 20 | pub runtime_dir: PathBuf 21 | } 22 | 23 | impl Context { 24 | pub fn initialize(args: &Args) -> Context { 25 | let runtime_dir = match process_path::get_executable_path() { 26 | Some(path) => path.parent().unwrap().to_path_buf(), 27 | None => match env::current_dir() { 28 | Ok(path) => { 29 | println!( 30 | "The runtime directory could not be determined and \ 31 | the current working directory is instead assumed \ 32 | to be the runtime directory. If you didn't start \ 33 | the elmyra binary from its containing folder, you \ 34 | should quit now and start it from there instead, \ 35 | otherwise you will run into undefined behavior." 36 | ); 37 | 38 | path 39 | } 40 | Err(_) => { 41 | panic!( 42 | "The runtime directory could not be determined and \ 43 | the current working directory is invalid. Please \ 44 | make sure to execute the elmyra binary from its \ 45 | containing folder." 46 | ); 47 | } 48 | } 49 | }; 50 | 51 | 52 | let data_dir = match &args.data_dir { 53 | Some(path) => PathBuf::from(path), 54 | None => runtime_dir.to_path_buf() 55 | }; 56 | 57 | for (dir, disposable_content) in &[ 58 | ("imports", true), 59 | ("upload", true), 60 | ("visualizations", false) 61 | ] { 62 | let required_dir = data_dir.join(dir); 63 | 64 | if required_dir.is_dir() { 65 | if *disposable_content { 66 | fs::remove_dir_all(&required_dir).ok(); 67 | } else { 68 | continue 69 | } 70 | } 71 | 72 | if let Err(err) = fs::create_dir(required_dir) { 73 | panic!("Could not create the required '{}' directory inside the data directory {}, received error: {}", dir, data_dir.display(), err) 74 | } 75 | } 76 | 77 | let blender_executable = match &args.blender_path { 78 | Some(path) => PathBuf::from(path), 79 | None => runtime_dir.join(library::BLENDER) 80 | }; 81 | 82 | let ffmpeg_executable = match &args.ffmpeg_path { 83 | Some(path) => PathBuf::from(path), 84 | None => runtime_dir.join(library::FFMPEG) 85 | }; 86 | 87 | Context { 88 | blender_executable, 89 | data_dir, 90 | ffmpeg_executable, 91 | runtime_dir 92 | } 93 | } 94 | 95 | pub fn blender_script_with_env(&self, script: &str) -> Command { 96 | let mut command = Command::new(&self.blender_executable); 97 | 98 | command.env("ELMYRA_DATA_DIR", &self.data_dir); 99 | command.env("ELMYRA_FFMPEG_EXECUTABLE", &self.ffmpeg_executable); 100 | command.env("ELMYRA_RUNTIME_DIR", &self.runtime_dir); 101 | 102 | command.arg("--background"); 103 | command.arg("--python").arg(self.runtime_dir.join(script)); 104 | command.arg("--"); 105 | 106 | command 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/rust/internal_routes.rs: -------------------------------------------------------------------------------- 1 | pub mod generate; 2 | pub mod import; 3 | pub mod preview; 4 | pub mod upload; 5 | pub mod visualizations; 6 | -------------------------------------------------------------------------------- /src/rust/internal_routes/generate.rs: -------------------------------------------------------------------------------- 1 | use rocket::State; 2 | use rocket_contrib::json::Json; 3 | 4 | use crate::context::Context; 5 | use crate::process; 6 | 7 | #[derive(Deserialize)] 8 | #[serde(rename_all = "camelCase")] 9 | pub struct Parameters { 10 | pub camera_type: String, 11 | pub id: String, 12 | pub import_id: String, 13 | pub media_animated: bool, 14 | pub media_height: usize, 15 | pub media_length: Option, 16 | pub media_width: usize, 17 | pub modifier_section_axis: Option, 18 | pub modifier_section_level: Option, 19 | pub modifier_section_level_from: Option, 20 | pub modifier_section_level_to: Option, 21 | pub modifier_type: String, 22 | pub orient_flip_horizontally: bool, 23 | pub orient_flip_vertically: bool, 24 | pub orient_rotate_x: f32, 25 | pub orient_rotate_y: f32, 26 | pub orient_rotate_z: f32, 27 | pub style_type: String 28 | } 29 | 30 | #[post("/generate", data = "", rank = 2)] 31 | pub fn generate(context: State, parameters: Json) -> Result<(), String> { 32 | let mut command = context.blender_script_with_env("python/generate.py"); 33 | 34 | command.arg("--id").arg(¶meters.id); 35 | command.arg("--import-id").arg(¶meters.import_id); 36 | command.arg("--media-animated").arg(if parameters.media_animated { "true" } else { "false" }); 37 | command.arg("--media-height").arg(parameters.media_height.to_string()); 38 | if let Some(length) = parameters.media_length { command.arg("--media-length").arg(length.to_string()); } 39 | command.arg("--media-width").arg(parameters.media_width.to_string()); 40 | if let Some(ref axis) = parameters.modifier_section_axis { command.arg("--modifier-section-axis").arg(axis); } 41 | if let Some(level) = parameters.modifier_section_level { command.arg("--modifier-section-level").arg(level.to_string()); } 42 | if let Some(level_from) = parameters.modifier_section_level_from { command.arg("--modifier-section-level-from").arg(level_from.to_string()); } 43 | if let Some(level_to) = parameters.modifier_section_level_to { command.arg("--modifier-section-level-to").arg(level_to.to_string()); } 44 | command.arg("--modifier-type").arg(¶meters.modifier_type); 45 | command.arg("--orient-flip-horizontally").arg(if parameters.orient_flip_horizontally { "true" } else { "false" }); 46 | command.arg("--orient-flip-vertically").arg(if parameters.orient_flip_vertically { "true" } else { "false" }); 47 | command.arg("--orient-rotate-x").arg(parameters.orient_rotate_x.to_string()); 48 | command.arg("--orient-rotate-y").arg(parameters.orient_rotate_y.to_string()); 49 | command.arg("--orient-rotate-z").arg(parameters.orient_rotate_z.to_string()); 50 | command.arg("--style-type").arg(¶meters.style_type); 51 | command.arg("--camera-type").arg(¶meters.camera_type); 52 | 53 | match command.output() { 54 | Ok(output) => if output.status.success() { 55 | Ok(()) 56 | } else { 57 | let blender_output = process::debug_output(output); 58 | Err(format!("The blender child process returned an error exit code.\n\n{}", blender_output)) 59 | } 60 | Err(_) => Err("The blender child process could not be executed.".to_string()) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/rust/internal_routes/import.rs: -------------------------------------------------------------------------------- 1 | use rocket::Data; 2 | use rocket::State; 3 | use rocket::request::Form; 4 | use rocket_contrib::json::Json; 5 | 6 | use crate::process; 7 | use crate::context::Context; 8 | use crate::uuid; 9 | 10 | const SUPPORTED_FORMATS: &[&str] = &[ 11 | "3ds", 12 | "blend", 13 | "dae", 14 | "fbx", 15 | "obj", 16 | "ply", 17 | "stl" 18 | ]; 19 | 20 | #[derive(Serialize)] 21 | #[serde(rename_all = "camelCase")] 22 | pub struct ImportResponse { 23 | import_id: String 24 | } 25 | 26 | #[derive(FromForm)] 27 | pub struct ImportUrlFormData { 28 | pub url: String 29 | } 30 | 31 | #[post("/import/file/", data = "", rank = 2)] 32 | pub fn import_from_file( 33 | context: State, 34 | file: Data, 35 | format: String 36 | ) -> Result, String> { 37 | if !SUPPORTED_FORMATS.contains(&format.as_str()) { 38 | return Err("The supplied file format is not supported.".to_string()); 39 | } 40 | 41 | let import_id = uuid::generate(); 42 | let upload_path = format!("upload/{}.{}", import_id, format); 43 | 44 | match file.stream_to_file(context.data_dir.join(&upload_path)) { 45 | Ok(_) => import(context, import_id, format, upload_path), 46 | Err(_) => Err("The import file upload could not be written to disk.".to_string()) 47 | } 48 | } 49 | 50 | #[post("/import/url/", data = "
", rank = 2)] 51 | pub fn import_from_url( 52 | context: State, 53 | form: Form, 54 | format: String 55 | ) -> Result, String> { 56 | if !SUPPORTED_FORMATS.contains(&format.as_str()) { 57 | return Err("The supplied file format is not supported.".to_string()); 58 | } 59 | 60 | import(context, uuid::generate(), format, form.url.clone()) 61 | } 62 | 63 | fn import( 64 | context: State, 65 | import_id: String, 66 | format: String, 67 | url: String 68 | ) -> Result, String> { 69 | let mut command = context.blender_script_with_env("python/import.py"); 70 | 71 | command.arg("--url").arg(&url); 72 | command.arg("--format").arg(&format); 73 | command.arg("--import-id").arg(&import_id); 74 | 75 | let import_file = format!("imports/{}/imported.blend", import_id); 76 | 77 | match command.output() { 78 | Ok(output) => { 79 | if output.status.success() { 80 | if context.data_dir.join(import_file).exists() { 81 | Ok(Json(ImportResponse { import_id: import_id })) 82 | } else { 83 | let blender_output = process::debug_output(output); 84 | Err(format!("The blender child process did not produce the required output.\n\n{}", blender_output)) 85 | } 86 | } else { 87 | let blender_output = process::debug_output(output); 88 | Err(format!("The blender child process returned an error exit code.\n\n{}", blender_output)) 89 | } 90 | } 91 | Err(_) => Err("The blender child process could not be executed.".to_string()) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/rust/internal_routes/preview.rs: -------------------------------------------------------------------------------- 1 | use rocket::response::NamedFile; 2 | use rocket::State; 3 | 4 | use crate::context::Context; 5 | 6 | #[get("/preview/", rank = 2)] 7 | pub fn preview(context: State, id: String) -> Option { 8 | if id.contains("..") { 9 | return None; 10 | } 11 | 12 | let preview_obj_path = format!("imports/{}/preview.obj", id); 13 | 14 | NamedFile::open(context.data_dir.join(preview_obj_path)).ok() 15 | } 16 | -------------------------------------------------------------------------------- /src/rust/internal_routes/upload.rs: -------------------------------------------------------------------------------- 1 | use rocket::Data; 2 | use rocket::State; 3 | 4 | use crate::context::Context; 5 | use crate::process; 6 | use crate::uuid; 7 | 8 | #[post("/upload/", data = "", rank = 2)] 9 | pub fn upload(context: State, id: String, file: Data) -> Result<(), String> { 10 | let upload_id = uuid::generate(); 11 | let upload_path = format!("upload/{}.blend", upload_id); 12 | 13 | match file.stream_to_file(context.data_dir.join(&upload_path)) { 14 | Ok(_) => update(context, id, upload_id), 15 | Err(_) => Err("The update file upload could not be written to disk.".to_string()) 16 | } 17 | } 18 | 19 | fn update( 20 | context: State, 21 | id: String, 22 | upload_id: String 23 | ) -> Result<(), String> { 24 | let mut command = context.blender_script_with_env("python/update.py"); 25 | 26 | command.arg("--id").arg(&id); 27 | command.arg("--upload-id").arg(&upload_id); 28 | 29 | match command.output() { 30 | Ok(output) => { 31 | if output.status.success() { 32 | Ok(()) 33 | } else { 34 | let blender_output = process::debug_output(output); 35 | Err(format!("The blender child process returned an error exit code.\n\n{}", blender_output)) 36 | } 37 | } 38 | Err(_) => Err("The blender child process could not be executed.".to_string()) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/rust/internal_routes/visualizations.rs: -------------------------------------------------------------------------------- 1 | use rocket::State; 2 | use rocket::response::status::NotFound; 3 | use rocket_contrib::json::Json; 4 | use serde_json; 5 | use std::fs; 6 | use std::path::Path; 7 | 8 | use crate::context::Context; 9 | use crate::meta::Meta; 10 | 11 | #[derive(Serialize)] 12 | pub struct Version { 13 | id: String, 14 | meta: Meta 15 | } 16 | 17 | #[derive(Serialize)] 18 | pub struct Visualization { 19 | id: String, 20 | versions: Vec 21 | } 22 | 23 | #[get("/visualizations", rank = 2)] 24 | pub fn visualizations(context: State) -> Result>, NotFound> { 25 | let visualizations_dir = context.data_dir.join("visualizations"); 26 | 27 | match read_visualizations(visualizations_dir.as_path()) { 28 | Ok(visualizations) => Ok(Json(visualizations)), 29 | Err(err) => Err(NotFound(err)) // TODO: Could be 404 (dir missing) or 500 (corrupt visualization), maybe 404 should be 500 as well though, it's corrupt state in a way 30 | } 31 | } 32 | 33 | // TODO: Revisit exact handling of errors (e.g. possibly temporary read errors vs. definitive data integrity problems differentiation) 34 | 35 | fn read_visualizations(directory: &Path) -> Result, String> { 36 | match directory.read_dir() { 37 | Ok(read_dir) => { 38 | let mut visualizations = vec![]; 39 | 40 | for dir_entry_result in read_dir { 41 | match dir_entry_result { 42 | Ok(dir_entry) => match read_versions(&dir_entry.path()) { 43 | Ok(versions) => { 44 | let visualization = Visualization { 45 | id: dir_entry.file_name().into_string().unwrap(), 46 | versions 47 | }; 48 | 49 | visualizations.push(visualization); 50 | } 51 | Err(versions_err) => return Err(versions_err) 52 | } 53 | Err(_) => return Err("Could not read visualization directory.".to_string()) 54 | } 55 | } 56 | 57 | Ok(visualizations) 58 | } 59 | Err(_) => return Err("Could not read visualizations directory.".to_string()) 60 | } 61 | } 62 | 63 | fn read_versions(directory: &Path) -> Result, String> { 64 | match directory.read_dir() { 65 | Ok(read_dir) => { 66 | let mut versions = vec![]; 67 | 68 | for dir_entry_result in read_dir { 69 | match dir_entry_result { 70 | Ok(dir_entry) => match read_meta(&dir_entry.path()) { 71 | Ok(meta) => { 72 | let version = Version { 73 | id: dir_entry.file_name().into_string().unwrap(), 74 | meta 75 | }; 76 | 77 | versions.push(version) 78 | } 79 | Err(meta_err) => return Err(meta_err) 80 | } 81 | Err(_) => return Err("Could not read version directory.".to_string()) 82 | } 83 | } 84 | 85 | Ok(versions) 86 | } 87 | Err(_) => return Err("Could not read visualization directory.".to_string()) 88 | } 89 | } 90 | 91 | fn read_meta(directory: &Path) -> Result { 92 | match fs::read_to_string(directory.join("meta.json")) { 93 | Ok(meta_json) => { 94 | let meta: Meta = match serde_json::from_str(&meta_json) { 95 | Ok(meta) => meta, 96 | Err(_) => return Err("Could not deserialize meta.".to_string()) 97 | }; 98 | 99 | Ok(meta) 100 | } 101 | Err(_) => return Err("Could not read meta.".to_string()) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/rust/main.rs: -------------------------------------------------------------------------------- 1 | #![feature(proc_macro_hygiene, decl_macro)] 2 | 3 | #[macro_use] extern crate rocket; 4 | #[macro_use] extern crate serde_derive; 5 | 6 | use clap::Clap; 7 | use rocket::config::{Config, Environment}; 8 | use rocket_contrib::serve::StaticFiles; 9 | 10 | mod args; 11 | mod context; 12 | mod internal_routes; 13 | mod library; 14 | mod meta; 15 | mod process; 16 | mod public_routes; 17 | mod renderer; 18 | mod uuid; 19 | 20 | use args::Args; 21 | use context::Context; 22 | 23 | #[cfg(debug_assertions)] 24 | const ENVIRONMENT: Environment = Environment::Development; 25 | 26 | #[cfg(not(debug_assertions))] 27 | const ENVIRONMENT: Environment = Environment::Production; 28 | 29 | fn main() { 30 | env_logger::init(); 31 | 32 | let args: Args = Args::parse(); 33 | let context = Context::initialize(&args); 34 | 35 | let config = Config::build(ENVIRONMENT) 36 | .address(&args.address) 37 | .port(args.port) 38 | .finalize() 39 | .unwrap(); 40 | 41 | let internal_routes = routes![ 42 | crate::internal_routes::generate::generate, 43 | crate::internal_routes::import::import_from_file, 44 | crate::internal_routes::import::import_from_url, 45 | crate::internal_routes::preview::preview, 46 | crate::internal_routes::upload::upload, 47 | crate::internal_routes::visualizations::visualizations 48 | ]; 49 | 50 | let public_routes = routes![ 51 | crate::public_routes::index::index, 52 | crate::public_routes::visualization::visualization, 53 | crate::public_routes::visualization::visualization_with_format 54 | ]; 55 | 56 | let static_dir = context.runtime_dir.join("static"); 57 | 58 | if !args.disable_rendering { 59 | renderer::start(args, context.clone()); 60 | } 61 | 62 | rocket::custom(config) 63 | .manage(context) 64 | .mount("/__static", StaticFiles::from(static_dir).rank(1)) 65 | .mount("/__internal", internal_routes) 66 | .mount("/", public_routes) 67 | .launch(); 68 | } 69 | -------------------------------------------------------------------------------- /src/rust/meta.rs: -------------------------------------------------------------------------------- 1 | #[derive(Deserialize, Serialize)] 2 | #[serde(rename_all = "camelCase")] 3 | pub struct FormatMeta { 4 | pub file_path: String, 5 | pub exported: String, 6 | pub processing_time: f32, 7 | pub file_size: usize 8 | } 9 | 10 | #[derive(Deserialize, Serialize)] 11 | #[serde(rename_all = "camelCase")] 12 | pub struct Meta { 13 | pub media_width: usize, 14 | pub media_height: usize, 15 | pub media_length: usize, 16 | pub media_animated: bool, 17 | pub media_fps: usize, 18 | pub media_frame_count: usize, 19 | pub processing: Option, 20 | pub render_device: Option, 21 | pub last_rendered_frame: Option, 22 | pub last_render_duration: Option, 23 | pub last_render: Option, 24 | pub last_rendered_samples: Option, 25 | pub mp4: Option, 26 | pub ogv: Option, 27 | pub webm: Option, 28 | pub gif: Option, 29 | pub jpg: Option, 30 | pub png: Option, 31 | pub svg: Option, 32 | #[serde(rename = "png.zip")] 33 | pub png_zip: Option, 34 | #[serde(rename = "svg.zip")] 35 | pub svg_zip: Option 36 | } 37 | -------------------------------------------------------------------------------- /src/rust/process.rs: -------------------------------------------------------------------------------- 1 | use std::process::Output; 2 | 3 | pub fn debug_output(output: Output) -> String { 4 | let stderr = String::from_utf8(output.stderr).unwrap(); 5 | let stdout = String::from_utf8(output.stdout).unwrap(); 6 | 7 | format!("stderr: {}\n\nstdout: {}", stderr, stdout) 8 | } 9 | -------------------------------------------------------------------------------- /src/rust/public_routes.rs: -------------------------------------------------------------------------------- 1 | pub mod index; 2 | pub mod visualization; 3 | -------------------------------------------------------------------------------- /src/rust/public_routes/index.rs: -------------------------------------------------------------------------------- 1 | use rocket::response::NamedFile; 2 | use rocket::State; 3 | 4 | use crate::context::Context; 5 | 6 | #[get("/", rank = 1)] 7 | pub fn index(context: State) -> Option { 8 | let index_html = context.runtime_dir.join("static/index.html"); 9 | 10 | NamedFile::open(index_html).ok() 11 | } 12 | -------------------------------------------------------------------------------- /src/rust/public_routes/visualization.rs: -------------------------------------------------------------------------------- 1 | use rocket::response::NamedFile; 2 | use rocket::State; 3 | use std::fs; 4 | 5 | use crate::context::Context; 6 | use crate::meta::Meta; 7 | 8 | #[get("//", rank = 3)] 9 | pub fn visualization( 10 | context: State, 11 | id: String, 12 | version: String 13 | ) -> Result, String> { 14 | serve_media(context, id, version, None) 15 | } 16 | 17 | #[get("///", rank = 3)] 18 | pub fn visualization_with_format( 19 | context: State, 20 | id: String, 21 | version: String, 22 | format: String 23 | ) -> Result, String> { 24 | serve_media(context, id, version, Some(&format)) 25 | } 26 | 27 | fn serve_media( 28 | context: State, 29 | id: String, 30 | version: String, 31 | optional_format: Option<&str> 32 | ) -> Result, String> { 33 | if id.contains("..") || version.contains("..") { 34 | return Err("(o_ _)ノ彡☆".to_string()); 35 | } 36 | 37 | if version == "latest" { 38 | match context.data_dir.join("visualizations").join(&id).read_dir() { 39 | Ok(read_dir) => { 40 | let mut versions = vec![]; 41 | 42 | for dir_entry_result in read_dir { 43 | match dir_entry_result { 44 | Ok(dir_entry) => versions.push(dir_entry.file_name().into_string().unwrap()), 45 | Err(_) => return Err("Could not read version directory.".to_string()) 46 | } 47 | } 48 | 49 | versions.sort(); 50 | 51 | serve_version(context, id, versions.last().unwrap().to_string(), optional_format) 52 | } 53 | Err(_) => return Err("Could not read visualization directory.".to_string()) 54 | } 55 | } else { 56 | serve_version(context, id, version, optional_format) 57 | } 58 | } 59 | 60 | fn serve_version( 61 | context: State, 62 | id: String, 63 | version: String, 64 | optional_format: Option<&str> 65 | ) -> Result, String> { 66 | match optional_format { 67 | Some(format) => match format { 68 | "thumbnail" => send_media(context, id, version, "thumbnail", "png", false), 69 | "blend" => send_media(context, id, version, "scene", "blend", true), 70 | "png" => send_media(context, id, version, "exported", "mp4", true), 71 | "jpg" => send_media(context, id, version, "exported", "jpg", true), 72 | "svg" => send_media(context, id, version, "exported", "svg", true), 73 | "mp4" => send_media(context, id, version, "exported", "mp4", true), 74 | "ogv" => send_media(context, id, version, "exported", "ogv", true), 75 | "webm" => send_media(context, id, version, "exported", "webm", true), 76 | "gif" => send_media(context, id, version, "exported", "gif", true), 77 | "png.zip" => send_media(context, id, version, "exported", "png.zip", true), 78 | "svg.zip" => send_media(context, id, version, "exported", "svg.zip", true), 79 | _ => return Err("Unknown format requested.".to_string()) 80 | } 81 | None => { 82 | let meta_file = context.data_dir.join("visualizations").join(&id).join(&version).join("meta.json"); 83 | 84 | match fs::read_to_string(meta_file) { 85 | Ok(meta_json) => { 86 | let meta: Meta = match serde_json::from_str(&meta_json) { 87 | Ok(meta) => meta, 88 | Err(_) => return Err("Could not deserialize meta.".to_string()) 89 | }; 90 | 91 | if meta.media_animated { 92 | send_media(context, id, version, "exported", "mp4", false) 93 | } else { 94 | send_media(context, id, version, "exported", "png", false) 95 | } 96 | } 97 | Err(_) => return Err("Could not read meta.".to_string()) 98 | } 99 | } 100 | } 101 | } 102 | 103 | fn send_media( 104 | context: State, 105 | id: String, 106 | version: String, 107 | base_name: &str, 108 | format: &str, 109 | download: bool 110 | ) -> Result, String> { 111 | let extended_name = format!("{}.{}", base_name, format); 112 | let file = context.data_dir.join("visualizations").join(&id).join(&version).join(extended_name); 113 | 114 | if download { 115 | let _download_filename = format!("{}.{}", id, format); 116 | 117 | // TODO: Set custom response headers so that the user agent 118 | // is hinted into offering a download dialog with the 119 | // proposed _download_filename. 120 | Ok(NamedFile::open(file).ok()) 121 | } else { 122 | Ok(NamedFile::open(file).ok()) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/rust/renderer.rs: -------------------------------------------------------------------------------- 1 | use log::{debug, error}; 2 | use std::{thread, time::Duration}; 3 | 4 | use crate::args::Args; 5 | use crate::context::Context; 6 | use crate::process; 7 | 8 | pub fn start(args: Args, context: Context) { 9 | thread::spawn(move || { 10 | let visualizations_dir = context.data_dir.join("visualizations"); 11 | 12 | loop { 13 | match visualizations_dir.read_dir() { 14 | Ok(read_dir) => { 15 | for dir_entry_result in read_dir { 16 | match dir_entry_result { 17 | Ok(dir_entry) => { 18 | let mut command = context.blender_script_with_env("python/render.py"); 19 | 20 | command.arg("--id").arg(dir_entry.file_name().into_string().unwrap()); 21 | command.arg("--device").arg(args.render_device.to_str()); 22 | command.arg("--target-time").arg(args.render_target_time.to_string()); 23 | 24 | match command.output() { 25 | Ok(output) => if output.status.success() { 26 | debug!("The blender child process finished"); 27 | } else { 28 | let blender_output = process::debug_output(output); 29 | error!("The blender child process returned an error exit code.\n\n{}", blender_output) 30 | } 31 | Err(_) => error!("The blender child process could not be executed.") 32 | } 33 | } 34 | Err(_) => debug!("Could not read visualization directory.") 35 | } 36 | } 37 | } 38 | Err(_) => debug!("Could not read visualizations directory.") 39 | } 40 | 41 | thread::sleep(Duration::from_secs(1)) 42 | } 43 | }); 44 | } 45 | -------------------------------------------------------------------------------- /src/rust/uuid.rs: -------------------------------------------------------------------------------- 1 | use uuid::Uuid; 2 | 3 | pub fn generate() -> String { 4 | Uuid::new_v4().to_simple().to_string() 5 | } 6 | -------------------------------------------------------------------------------- /src/scss/base.scss: -------------------------------------------------------------------------------- 1 | $title_color: #444; 2 | $link_color: #666; 3 | $link_hover_color: #777; 4 | 5 | #visualizations { 6 | display: flex; 7 | flex-flow: row wrap; 8 | } 9 | 10 | .visualization { 11 | flex: 0 0 auto; 12 | margin: 50px 0 0 50px; 13 | 14 | a { 15 | color: $link_color; 16 | text-decoration: none; 17 | 18 | &:hover { 19 | color: $link_hover_color; 20 | } 21 | } 22 | 23 | .preview { 24 | background-color: #999; 25 | background-image: url(/__static/checker.png); 26 | box-shadow: inset 0 0 20px #333, 0 0 20px #ccc; 27 | position: relative; 28 | 29 | &.active { 30 | background-color: #CBA164; 31 | background-image: url(/__static/checker-active.png); 32 | box-shadow: inset 0 0 20px #333, 0 0 20px #ccc; 33 | } 34 | 35 | &.pending { 36 | box-shadow: 0 0 20px #ccc; 37 | background-image: none; 38 | font-size: 1.5rem; 39 | 40 | a { 41 | text-align: center; 42 | color: #eee; 43 | } 44 | } 45 | 46 | a { 47 | align-items: center; 48 | cursor: pointer; 49 | display: flex; 50 | height: 270px; 51 | justify-content: center; 52 | width: 480px; 53 | 54 | img { 55 | max-height: 100%; 56 | max-width: 100%; 57 | } 58 | } 59 | 60 | span.overlay { 61 | background-color: #cecece; 62 | border-bottom-left-radius: 10px; 63 | font-size: 2rem; 64 | padding: 10px; 65 | pointer-events: none; 66 | position: absolute; 67 | right: 0; 68 | top: 0; 69 | 70 | svg { 71 | display: block !important; 72 | } 73 | } 74 | } 75 | 76 | .menu { 77 | align-items: center; 78 | display: flex; 79 | justify-content: space-between; 80 | 81 | .title { 82 | color: $title_color; 83 | font-weight: bold; 84 | } 85 | 86 | .version { 87 | position: relative; 88 | top: -8px; 89 | } 90 | 91 | .controls { 92 | display: flex; 93 | justify-content: space-between; 94 | width: 290px; 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/scss/main.scss: -------------------------------------------------------------------------------- 1 | @use '../../node_modules/bootstrap/dist/css/bootstrap.min'; 2 | @use 'base'; 3 | @use 'navigation'; 4 | @use 'preview'; 5 | @use 'wizard'; 6 | -------------------------------------------------------------------------------- /src/scss/navigation.scss: -------------------------------------------------------------------------------- 1 | nav { 2 | background-color: #444; 3 | display: flex; 4 | 5 | a, 6 | div { 7 | color: #ddd; 8 | padding: 15px; 9 | } 10 | 11 | a { 12 | cursor: pointer; 13 | display: block; 14 | 15 | &.active { 16 | background-color: #4a4a4a; 17 | border-bottom: 5px solid rgb(255, 153, 0); 18 | color: #fff; 19 | padding-bottom: 10px; 20 | } 21 | 22 | &:hover { 23 | background-color: #4a4a4a; 24 | border-bottom: 5px solid #aaa; 25 | color: #fff; 26 | padding-bottom: 10px; 27 | text-decoration: none; 28 | } 29 | } 30 | 31 | a#logo { 32 | color: #fff; 33 | } 34 | 35 | span#version { 36 | color: rgb(255, 153, 0); 37 | font-weight: bold; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/scss/preview.scss: -------------------------------------------------------------------------------- 1 | #preview { 2 | align-items: center; 3 | background-color: rgba(0, 0, 0, 80%); 4 | color: #fff; 5 | display: flex; 6 | height: 100vh; 7 | justify-content: center; 8 | left: 0; 9 | position: absolute; 10 | top: 0; 11 | width: 100vw; 12 | } 13 | 14 | #preview-content { 15 | border: 1px solid #fff; 16 | box-shadow: 0 0 20px #000; 17 | display: flex; 18 | max-height: 80vh; 19 | max-width: 80vw; 20 | 21 | iframe { 22 | min-height: 405px; 23 | min-width: 720px; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/scss/wizard.scss: -------------------------------------------------------------------------------- 1 | #wizard { 2 | display: flex; 3 | height: 100vh; 4 | flex-direction: column; 5 | 6 | main { 7 | align-items: center; 8 | display: flex; 9 | flex: 1; 10 | flex-flow: row wrap; 11 | justify-content: center; 12 | 13 | .option { 14 | display: flex; 15 | flex-direction: column; 16 | margin: 20px; 17 | max-height: 480px; 18 | text-align: center; 19 | width: 240px; 20 | 21 | h1 { 22 | font-size: 2.2rem; 23 | } 24 | 25 | .description { 26 | flex: 1; 27 | } 28 | 29 | button { 30 | margin: 20px; 31 | } 32 | } 33 | 34 | .data-input { 35 | display: flex; 36 | flex-direction: column; 37 | text-align: center; 38 | max-height: 100%; 39 | width: 640px; 40 | 41 | form { 42 | display: flex; 43 | 44 | input, button { 45 | margin: 5px; 46 | } 47 | } 48 | } 49 | 50 | .tool { 51 | display: flex; 52 | flex-direction: column; 53 | text-align: center; 54 | max-height: 100%; 55 | max-width: 100%; 56 | } 57 | } 58 | } 59 | 60 | .insert-group { 61 | display: flex; 62 | flex-flow: row wrap; 63 | width: 240px; 64 | 65 | input, 66 | .insert { 67 | padding: 4px; 68 | } 69 | 70 | input { 71 | flex: 1; 72 | flex-basis: auto; 73 | text-align: center; 74 | width: 60px; 75 | 76 | &:first-child { 77 | border: 1px solid #ccc; 78 | border-radius: 2px 0 0 2px; 79 | } 80 | 81 | &:last-child { 82 | border: 1px solid #ccc; 83 | border-radius: 0 2px 2px 0; 84 | } 85 | } 86 | 87 | .insert { 88 | background-color: #eee; 89 | border: 1px solid #ccc; 90 | border-width: 1px 0; 91 | } 92 | } 93 | 94 | .axis { 95 | font-weight: bold; 96 | 97 | .x { color: red; } 98 | .y { color: green; } 99 | .z { color: blue; } 100 | } 101 | 102 | #dropzone { 103 | align-items: center; 104 | border: 10px dashed #ececec; 105 | border-radius: 60px; 106 | color: #cecece; 107 | cursor: pointer; 108 | display: flex; 109 | height: 400px; 110 | justify-content: center; 111 | width: 100%; 112 | 113 | &.drag-registered { 114 | border: 10px dashed #f90; 115 | } 116 | 117 | * { 118 | pointer-events: none; 119 | } 120 | } 121 | --------------------------------------------------------------------------------