├── .gitignore ├── .npmignore ├── LICENSE.md ├── README.md ├── bin └── help.txt ├── package-lock.json ├── package.json ├── src ├── commit.js ├── downloads-folder.js ├── ffmpeg-gif.js ├── ffmpeg-mp4.js ├── ffmpeg-sequence.js ├── get-ffmpeg-cmd.js ├── html.js ├── ideas.txt ├── index.js ├── install.js ├── instrumentation │ ├── client-enable-hot.js │ ├── client-enable-output.js │ └── client.js ├── logger.js ├── middleware.js ├── plugins │ ├── plugin-env.js │ ├── plugin-glsl.js │ ├── plugin-resolve.js │ └── transform-installer.js ├── templates │ ├── alt-index.html │ ├── default.js │ ├── index.html │ ├── p5.js │ ├── penplot.js │ ├── regl.js │ ├── shader.js │ ├── three.js │ └── two.js ├── util.js └── walk-local-deps.js └── test ├── fixtures ├── auto-install-test-2.js ├── auto-install-test.js ├── bar.js ├── deep │ └── test.js ├── depth-0.js ├── depth-1.js ├── depth-2.js ├── esm-test.js ├── foo.js ├── second.js ├── shader-import.js ├── shader-require.js └── shader.glsl ├── template-util.js ├── template.js ├── test-glsl.js └── test-walk-deps.js /.gitignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | *.log 4 | .DS_Store 5 | bundle.js 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | bower_components 2 | node_modules 3 | *.log 4 | .DS_Store 5 | bundle.js 6 | test 7 | test.js 8 | demo/ 9 | .npmignore 10 | LICENSE.md -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2017 Matt DesLauriers 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 18 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 19 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE 20 | OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # canvas-sketch-cli 2 | 3 | See [canvas-sketch](https://github.com/mattdesl/canvas-sketch) for docs. 4 | 5 | ## License 6 | 7 | MIT, see [LICENSE.md](http://github.com/mattdesl/canvas-sketch-cli/blob/master/LICENSE.md) for details. 8 | -------------------------------------------------------------------------------- /bin/help.txt: -------------------------------------------------------------------------------- 1 | Usage: 2 | canvas-sketch [file] [opts] -- [browserifyArgs] 3 | 4 | Examples: 5 | canvas-sketch my-file.js 6 | canvas-sketch --build --dir public/ 7 | canvas-sketch --new --template=three --open 8 | canvas-sketch src/sketch.js --new 9 | 10 | Options: 11 | --help, -h Show help message 12 | --version, -v Display version 13 | --new, -n Stub out a new sketch 14 | --template, -t Set the template to use with --new, 15 | e.g. --template=three or --template=penplot 16 | --open, -o Open browser on run 17 | --hot Enable Hot Reloading during development 18 | --output Set output folder for exported sketch files 19 | --dir, -d Set output directory, defaults to '.' 20 | --port, -p Server port, defaults to 9966 21 | --no-install Disable auto-installation on run 22 | --force, -f Forces overwrite with --new flag 23 | --pushstate, -P Enable SPA/Pushstate file serving 24 | --quiet Do not log to stderr 25 | --build Build the sketch into HTML and JS files 26 | --no-compress Disable compression/minification during build 27 | --inline When building, inline all JS into a single HTML 28 | --name The name of the JS file, defaults to input file name 29 | --js The served JS src string, defaults to name 30 | --html The HTML input file, defaults to a basic template 31 | --stream, -S Enable ffmpeg streaming for MP4/GIF formats 32 | --https Use HTTPS (SSL) in dev server instead of HTTP 33 | --source-map Source map option, can be false, "inline", 34 | "external", or "auto" (default) 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "canvas-sketch-cli", 3 | "version": "1.15.0", 4 | "description": "A CLI used alongside canvas-sketch", 5 | "main": "./src/index.js", 6 | "bin": { 7 | "canvas-sketch": "src/index.js", 8 | "canvas-sketch-cli": "src/index.js", 9 | "canvas-sketch-gif": "src/ffmpeg-gif.js", 10 | "canvas-sketch-mp4": "src/ffmpeg-mp4.js" 11 | }, 12 | "license": "MIT", 13 | "author": { 14 | "name": "Matt DesLauriers", 15 | "email": "dave.des@gmail.com", 16 | "url": "https://github.com/mattdesl" 17 | }, 18 | "dependencies": { 19 | "@babel/core": "^7.4.3", 20 | "@babel/plugin-syntax-async-generators": "^7.0.0", 21 | "@babel/plugin-syntax-dynamic-import": "^7.0.0", 22 | "@babel/plugin-syntax-object-rest-spread": "^7.0.0", 23 | "@babel/plugin-transform-modules-commonjs": "^7.4.3", 24 | "body-parser": "^1.19.0", 25 | "browserify": "^17.0.0", 26 | "budo": "^11.8.3", 27 | "busboy": "^1.6.0", 28 | "chalk": "^2.4.1", 29 | "cli-format": "^3.0.9", 30 | "concat-stream": "^1.6.2", 31 | "convert-source-map": "^1.6.0", 32 | "cross-spawn": "^6.0.5", 33 | "dateformat": "^3.0.3", 34 | "defined": "^1.0.0", 35 | "duplexer2": "^0.1.4", 36 | "esmify": "^2.1.1", 37 | "filenamify": "^2.1.0", 38 | "from2-string": "^1.1.0", 39 | "get-stdin": "^6.0.0", 40 | "glslify": "^6.3.0", 41 | "html-minifier": "^3.5.17", 42 | "install-if-needed": "^1.0.4", 43 | "is-builtin-module": "^2.0.0", 44 | "is-error": "^2.2.1", 45 | "konan": "^2.1.1", 46 | "loose-envify": "^1.4.0", 47 | "maxstache": "^1.0.7", 48 | "minimist": "^1.2.0", 49 | "mkdirp": "^0.5.1", 50 | "ora": "^2.1.0", 51 | "pretty-bytes": "^5.1.0", 52 | "pretty-ms": "^3.2.0", 53 | "require-package-name": "^2.0.1", 54 | "resolve": "^1.8.1", 55 | "resolve-global": "^1.0.0", 56 | "right-now": "^1.0.0", 57 | "rimraf": "^2.6.2", 58 | "semver": "^5.5.0", 59 | "subarg": "^1.0.0", 60 | "tempy": "^0.3.0", 61 | "terser": "^5.15.0", 62 | "through2": "^2.0.3" 63 | }, 64 | "devDependencies": { 65 | "@ffmpeg-installer/ffmpeg": "^1.0.19", 66 | "@texel/color": "^1.1.1", 67 | "canvas-sketch": "^0.7.7", 68 | "regl": "^1.3.7", 69 | "tape": "^4.9.1" 70 | }, 71 | "scripts": { 72 | "test": "node test.js" 73 | }, 74 | "keywords": [ 75 | "canvas", 76 | "generative", 77 | "art", 78 | "sketch", 79 | "sketching", 80 | "webgl", 81 | "glsl", 82 | "2d" 83 | ], 84 | "engines": { 85 | "node": ">=8", 86 | "npm": ">=5" 87 | }, 88 | "repository": { 89 | "type": "git", 90 | "url": "git://github.com/mattdesl/canvas-sketch-cli.git" 91 | }, 92 | "homepage": "https://github.com/mattdesl/canvas-sketch-cli", 93 | "bugs": { 94 | "url": "https://github.com/mattdesl/canvas-sketch-cli/issues" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/commit.js: -------------------------------------------------------------------------------- 1 | const dateformat = require('dateformat'); 2 | const { exec } = require('child_process'); 3 | const path = require('path'); 4 | 5 | module.exports = async function (opt = {}) { 6 | const logger = opt.logger; 7 | return execify('git status --porcelain') 8 | .catch(err => { 9 | if (err.message.includes('Not a git repository')) { 10 | const err = new Error(`Can't commit changes because the working directory is not a git repository`); 11 | err.hideStack = true; 12 | throw err; 13 | } 14 | throw err; 15 | }) 16 | .then(result => { 17 | result = result.trim(); 18 | if (result) { 19 | return doCommit(opt).then(() => ({ changed: true })); 20 | } else { 21 | if (!opt.quiet) { 22 | logger.log('Nothing new to commit.'); 23 | } 24 | return { changed: false }; 25 | } 26 | }) 27 | .then(result => { 28 | return execify(`git rev-parse --short HEAD`) 29 | .then(hash => { 30 | return { ...result, hash: hash.trim() }; 31 | }); 32 | }); 33 | }; 34 | 35 | function generateCommitMessage (entryName) { 36 | // TODO: Maybe figure out a nice naming pattern for the commit 37 | // message. Ideally it would take the timeStamp from the export function, 38 | // however we don't want to inject user-modifiable strings into exec... 39 | const date = dateformat(Date.now(), 'yyyy.mm.dd-HH.MM.ss'); 40 | const prefix = entryName ? `[${entryName}]` : ''; 41 | return `${prefix} ${date}`; 42 | } 43 | 44 | function doCommit (opt) { 45 | const msg = generateCommitMessage(opt.entry ? path.relative(opt.cwd, opt.entry) : null); 46 | return execify(`git add . && git commit -m "${msg}"`) 47 | .then(result => { 48 | if (opt.logger) { 49 | opt.logger.log('Committing latest changes...\n'); 50 | } 51 | if (!opt.quiet) console.log(result); 52 | }); 53 | } 54 | 55 | function execify (cmd) { 56 | return new Promise((resolve, reject) => { 57 | exec(cmd, (err, stdout, stderr) => { 58 | if (err) return reject(err); 59 | stdout = stdout.toString(); 60 | stderr = stderr.toString(); 61 | if (stderr && stderr.length > 0) console.error(stderr); 62 | resolve(stdout); 63 | }); 64 | }); 65 | } 66 | -------------------------------------------------------------------------------- /src/downloads-folder.js: -------------------------------------------------------------------------------- 1 | // Modified from: 2 | // https://github.com/juliangruber/downloads-folder/blob/master/index.js 3 | 4 | const os = require("os"); 5 | const path = require("path"); 6 | const execSync = require("child_process").execSync; 7 | const statSync = require("fs").statSync; 8 | 9 | const funcMap = { 10 | darwin: darwin, 11 | freebsd: unix, 12 | linux: unix, 13 | sunos: unix, 14 | win32: windows 15 | }; 16 | 17 | module.exports = (opt) => { 18 | opt = opt || {}; 19 | var logger = opt.logger; 20 | var dir = process.env.CANVAS_SKETCH_OUTPUT; 21 | if (dir) { 22 | return path.isAbsolute(dir) ? dir : path.resolve(process.cwd(), dir); 23 | } 24 | dir = funcMap[os.platform()](); 25 | var stat; 26 | try { 27 | stat = statSync(dir) 28 | } catch (err) {} 29 | let err; 30 | if (!stat) { 31 | err = 'Could not find home Downloads directory, defaulting to cwd. Consider setting a CANVAS_SKETCH_OUTPUT environment variable instead, see here:\n\n https://github.com/mattdesl/canvas-sketch/blob/master/docs/exporting-artwork.md#changing-the-output-folder'; 32 | dir = null; 33 | } else if (!stat.isDirectory()) { 34 | err = 'The Downloads directory "' + dir + '" is not a folder, defaulting to cwd. Consider setting a CANVAS_SKETCH_OUTPUT environment variable instead, see here:\n\n https://github.com/mattdesl/canvas-sketch/blob/master/docs/exporting-artwork.md#changing-the-output-folder' 35 | dir = null; 36 | } 37 | if (err && logger) { 38 | logger.error(err); 39 | logger.pad(); 40 | } 41 | if (!dir) dir = process.cwd(); 42 | return dir; 43 | }; 44 | 45 | function darwin() { 46 | return process.env.HOME ? `${process.env.HOME}/Downloads` : null; 47 | } 48 | 49 | function unix() { 50 | let dir; 51 | try { 52 | dir = execSync("xdg-user-dir DOWNLOAD").trim(); 53 | } catch (_) {} 54 | if (dir && dir !== process.env.HOME) return dir; 55 | else return process.env.HOME ? `${process.env.HOME}/Downloads` : null; 56 | } 57 | 58 | function windows() { 59 | return process.env.USERPROFILE ? `${process.env.USERPROFILE}\\Downloads` : null; 60 | } 61 | -------------------------------------------------------------------------------- /src/ffmpeg-gif.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('./ffmpeg-sequence').start('gif'); 3 | -------------------------------------------------------------------------------- /src/ffmpeg-mp4.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('./ffmpeg-sequence').start('mp4'); 3 | -------------------------------------------------------------------------------- /src/ffmpeg-sequence.js: -------------------------------------------------------------------------------- 1 | const ora = require('ora'); 2 | const flat = (a, b) => a.concat(b); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const { promisify } = require('util'); 6 | const rimraf = promisify(require('rimraf')); 7 | const { spawnAsync, generateFileName } = require('./util'); 8 | const chalk = require('chalk'); 9 | const spawn = require('cross-spawn'); 10 | const minimist = require('minimist'); 11 | const { createLogger, getErrorDetails } = require('./logger'); 12 | const mkdirp = promisify(require('mkdirp')); 13 | const tempy = require('tempy'); 14 | const defined = require('defined'); 15 | const getFFMPEG = require('./get-ffmpeg-cmd'); 16 | 17 | const defaults = { 18 | fps: 24 19 | }; 20 | 21 | module.exports.args = (opt = {}) => { 22 | const argv = minimist(process.argv.slice(2), { 23 | boolean: [ 'quiet', 'force' ], 24 | default: defaults, 25 | alias: { 26 | inputFPS: [ 'input-fps' ], 27 | outputFPS: [ 'output-fps' ], 28 | force: [ 'f', 'y', 'yes' ], 29 | format: 'F', 30 | fps: [ 'r', 'rate' ], 31 | scale: 's', 32 | start: 'S', 33 | time: 't' 34 | } 35 | }); 36 | 37 | if (opt.format) { // force format when specified 38 | argv.format = argv.F = opt.format; 39 | } 40 | 41 | argv.input = argv._[0]; 42 | argv.output = argv._[1]; 43 | delete argv._; 44 | 45 | return argv; 46 | }; 47 | 48 | module.exports.convert = async (opt = {}) => { 49 | opt = Object.assign({}, opt); 50 | const cwd = path.resolve(opt.cwd || process.cwd()); 51 | const cmd = await getFFMPEG({ cwd }); 52 | const logger = createLogger(opt); 53 | const format = opt.format || 'gif'; 54 | 55 | let input = opt.input; 56 | let output = opt.output; 57 | 58 | if (!input) { 59 | logger.error('No entry file specified!', `Example usage:\n\n canvas-sketch-${format} frame-sequence/\n canvas-sketch-${format} foo/%03d.png`); 60 | logger.pad(); 61 | process.exit(1); 62 | } 63 | 64 | input = path.isAbsolute(input) ? input : path.resolve(cwd, input); 65 | if (output) { 66 | output = path.isAbsolute(output) ? output : path.resolve(cwd, output); 67 | } else { 68 | output = path.resolve(cwd, generateFileName('', `.${format}`)); 69 | } 70 | 71 | if (path.extname(output) === '') { 72 | output += `.${format}`; 73 | } 74 | 75 | if (!opt.force && fs.existsSync(output)) { 76 | throw new Error(`The output file already exists: ${path.relative(cwd, output)} (use -f to overwrite)`); 77 | } 78 | 79 | // See if the user didn't specify an exact input 80 | if (!/%[0-9]+d/.test(input)) { 81 | if (!fs.existsSync(input)) { 82 | logger.error(chalk.red(`Input file "${path.relative(cwd, input)}" not found`)); 83 | logger.pad(); 84 | process.exit(1); 85 | } 86 | 87 | // Check if we got a folder 88 | const stat = fs.statSync(input); 89 | if (stat.isDirectory()) { 90 | // Try to parse out the most relevant image sequence 91 | const files = fs.readdirSync(input); 92 | const images = files 93 | .filter(f => /^[0-9]+\.(png|gif|jpg|jpeg|bmp|tga|tiff)$/i.test(f)) 94 | .map(f => { 95 | const ext = path.extname(f); 96 | return { 97 | ext: path.extname(f), 98 | name: path.basename(f, ext) 99 | }; 100 | }); 101 | 102 | if (images.length === 0) { 103 | throw new Error(`Could not find any zero-padded png or jpg images in the folder ${path.relative(cwd, input)}, you may need to specify the files manually like so:\n\n canvas-sketch-${format} frames/%03d.png`); 104 | } 105 | 106 | const digits = Object.keys(images.reduce((dict, f) => { 107 | dict[f.name.length] = true; 108 | return dict; 109 | }, {})); 110 | const exts = Object.keys(images.reduce((dict, f) => { 111 | dict[f.ext] = true; 112 | return dict; 113 | }, {})); 114 | 115 | if (exts.length > 1) { // allow no extensions... 116 | throw new Error(`There are multiple sequences of different digit file extensions in the folder ${path.relative(cwd, input)}\nEither remove all the sequences except the one you wish to render, or specify an exact sequence:\n\n canvas-sketch-${format} frames/%03d.png`) 117 | } 118 | if (digits.length !== 1) { // don't allow no digits 119 | throw new Error(`There are multiple sequences of different digit lengths in the folder ${path.relative(cwd, input)}\nEither remove all the sequences except the one you wish to render, or specify an exact sequence:\n\n canvas-sketch-${format} frames/%03d.png`) 120 | } 121 | 122 | const numDigits = parseInt(digits[0]); 123 | const ext = exts[0] || ''; 124 | input = path.join(input, `%0${numDigits}d${ext}`); 125 | } 126 | } 127 | 128 | opt = Object.assign({}, opt, { input, output }); 129 | await mkdirp(path.dirname(output)); 130 | 131 | let converter = format === 'mp4' ? convertMP4 : convertGIF; 132 | 133 | let spinner = !opt.quiet && ora(`Writing ${chalk.bold(path.relative(cwd, output))}`).start(); 134 | try { 135 | await converter({ 136 | ...opt, 137 | cmd 138 | }); 139 | spinner.succeed(); 140 | } catch (err) { 141 | spinner.stop(); 142 | logger.log(err.message, { bullet: '' }); 143 | logger.log( 144 | chalk.red(chalk.bold('ffmpeg did not exit smoothly, see above output for details.')), 145 | { 146 | bullet: `${chalk.red(chalk.bold('✖'))} ` 147 | } 148 | ); 149 | } 150 | 151 | logger.pad(); 152 | }; 153 | 154 | async function convertMP4 (opt = {}) { 155 | const args = buildMP4Args(opt, false); 156 | logCommand(opt.cmd, args); 157 | return spawnAsync(opt.cmd, args); 158 | } 159 | 160 | module.exports.createStream = function (opt = {}) { 161 | return opt.format === 'gif' ? createGIFStream(opt) : createMP4Stream(opt); 162 | }; 163 | 164 | module.exports.createGIFStream = createGIFStream; 165 | function createGIFStream (opt = {}) { 166 | opt = Object.assign({ format: 'gif' }, defaults, opt); 167 | const encoding = opt.encoding || 'image/png'; 168 | const tmpDir = tempy.directory(); 169 | 170 | let digitCount; 171 | let framesProcessed = 0; 172 | let extension; 173 | 174 | return { 175 | promise: Promise.resolve(), 176 | encoding, 177 | writeFrame (file, filename) { 178 | return new Promise((resolve, reject) => { 179 | framesProcessed++; 180 | 181 | // Grab the digit count while we write the first frames 182 | if (!digitCount) { 183 | const digits = /([0-9]+)/.exec(filename); 184 | const digitStr = digits && digits[1]; 185 | if (!digits || !digitStr || digitStr.length <= 0) { 186 | return reject(new Error(`Filename ${filename} must be in a format with digits, such as 000.png`)); 187 | } 188 | digitCount = digitStr.length; 189 | } 190 | 191 | // Grab extension of frames, e.g. jpg/jpeg/png 192 | if (!extension) { 193 | extension = path.extname(filename); 194 | } 195 | 196 | // Write to temporary directory 197 | const filePath = path.join(tmpDir, filename); 198 | const writer = fs.createWriteStream(filePath); 199 | const stream = file.pipe(writer); 200 | writer.once('error', reject); 201 | stream.once('error', reject); 202 | writer.once('finish', resolve); 203 | }); 204 | }, 205 | async end () { 206 | if (framesProcessed === 0) { 207 | throw new Error('No frames processed'); 208 | } 209 | const input = path.join(tmpDir, `%0${digitCount}d${extension}`); 210 | const cmd = await getFFMPEG(); 211 | await convertGIF({ 212 | ...opt, 213 | cmd, 214 | input 215 | }); 216 | // cleanup tmp dir 217 | await rimraf(tmpDir); 218 | } 219 | }; 220 | } 221 | 222 | module.exports.createMP4Stream = createMP4Stream; 223 | function createMP4Stream (opt = {}) { 224 | opt = Object.assign({ format: 'mp4' }, defaults, opt); 225 | 226 | const encoding = opt.encoding || 'image/png'; 227 | const quiet = opt.quiet; 228 | const args = buildMP4Args(opt, true); 229 | let ffmpegStdin; 230 | let framesProcessed = 0; 231 | let exited = false; 232 | 233 | const cmdPromise = getFFMPEG(); 234 | 235 | const promise = cmdPromise.then(cmd => new Promise((resolve, reject) => { 236 | logCommand(cmd, args); 237 | const ffmpeg = spawn(cmd, args); 238 | const { stdin, stdout, stderr } = ffmpeg; 239 | ffmpegStdin = stdin; 240 | 241 | if (!quiet) { 242 | stdout.pipe(process.stdout); 243 | stderr.pipe(process.stderr); 244 | } 245 | 246 | stdin.on('error', (err) => { 247 | if (err.code !== 'EPIPE') { 248 | return reject(err); 249 | } 250 | }); 251 | 252 | ffmpeg.on('exit', async (status) => { 253 | exited = true; 254 | if (status) { 255 | return reject(new Error(`FFmpeg exited with status ${status}`)); 256 | } else { 257 | return resolve(); 258 | } 259 | }); 260 | })); 261 | 262 | return { 263 | promise: cmdPromise, 264 | encoding, 265 | stream: ffmpegStdin, 266 | writeBufferFrame (buffer) { 267 | return new Promise((resolve, reject) => { 268 | framesProcessed++; 269 | if (ffmpegStdin.writable && !exited) { 270 | ffmpegStdin.write(buffer); 271 | resolve(); 272 | } else { 273 | reject(new Error('WARN: MP4 stream is no longer writable')); 274 | } 275 | }); 276 | }, 277 | writeFrame (readableStream) { 278 | return new Promise((resolve, reject) => { 279 | framesProcessed++; 280 | if (ffmpegStdin && ffmpegStdin.writable && !exited) { 281 | readableStream.pipe(ffmpegStdin, { end: false }); 282 | readableStream.once('end', resolve); 283 | readableStream.once('error', reject); 284 | } else { 285 | reject(new Error('WARN: MP4 stream is no longer writable')); 286 | } 287 | }); 288 | }, 289 | end () { 290 | ffmpegStdin.end(); 291 | return promise.then(() => { 292 | if (framesProcessed === 0) return Promise.reject(new Error('No frames processed')); 293 | }); 294 | } 295 | }; 296 | } 297 | 298 | function parseMP4ImageEncoding (encoding) { 299 | if (encoding === 'image/png') return 'png'; 300 | if (encoding === 'image/jpeg') return 'mjpeg'; 301 | return null; 302 | } 303 | 304 | function buildMP4Args (opt = {}, isStream = false) { 305 | var ss = opt.start != null ? [ '-ss', opt.start ] : ''; 306 | var t = opt.time != null ? [ '-t', opt.time ] : ''; 307 | var fps = 'fps=' + (opt.fps) + ''; 308 | var scale = opt.scale != null ? ('scale=' + opt.scale) : ''; 309 | var filterStr = [ fps, scale ].filter(Boolean).join(','); 310 | var filter1 = [ '-vf', filterStr ]; 311 | var inFPS, outFPS; 312 | 313 | if (typeof opt.inputFPS === 'number' && isFinite(opt.inputFPS)) { 314 | // if user specifies --input-fps, take precedence over --fps / -r 315 | inFPS = opt.inputFPS; 316 | } else { 317 | // otherwise, use --fps or the default 24 fps 318 | inFPS = opt.fps; 319 | } 320 | 321 | // allow user to specify output rate, otherwise default to omitting it 322 | if (typeof opt.outputFPS === 'number' && isFinite(opt.outputFPS)) { 323 | outFPS = opt.outputFPS; 324 | } 325 | 326 | // build FPS commands 327 | var inFPSCommand = [ '-framerate', String(inFPS) ]; 328 | var outFPSCommand = outFPS != null ? [ '-r', String(outFPS) ] : false; 329 | 330 | const streamFormat = parseMP4ImageEncoding(opt.encoding || 'image/png'); 331 | const inputArgs = isStream 332 | ? [ '-f', 'image2pipe', '-c:v', streamFormat, '-i', '-' ] 333 | : [ '-i', opt.input ]; 334 | 335 | return [ 336 | inFPSCommand, 337 | inputArgs, 338 | filter1, 339 | '-y', 340 | '-an', 341 | '-preset', 'slow', 342 | '-c:v', 'libx264', 343 | '-movflags', 'faststart', 344 | '-profile:v', 'high', 345 | '-crf', '18', 346 | '-pix_fmt', 'yuv420p', 347 | // '-x264opts', 'YCgCo', 348 | ss, 349 | t, 350 | outFPSCommand, 351 | opt.output 352 | ].filter(Boolean).reduce(flat, []); 353 | } 354 | 355 | async function convertGIF (opt = {}) { 356 | opt = Object.assign({}, defaults, opt); 357 | 358 | const tmpFile = tempy.file({ extension: '.png' }); 359 | 360 | const ss = opt.start != null ? [ '-ss', String(opt.start) ] : ''; 361 | const t = opt.time != null ? [ '-t', String(opt.time) ] : ''; 362 | const inputFlag = [ '-i', opt.input ]; 363 | const fpsVal = defined(opt.fps, defaults.fps); 364 | const extname = path.extname(opt.input); 365 | // input framerate only seems accetable if you have a sequence... 366 | // so ignore it if user wants to convert, say, a MP4 file into GIF 367 | const inputFPS = (!extname || /^\.(png|tif|tga|tiff|webp|jpe?g|bmp)$/i.test(extname)) ? [ '-framerate', fpsVal ] : false; 368 | const outputFPS = [ '-r', fpsVal ]; 369 | const fps = 'fps=' + fpsVal + ''; 370 | let scale = ''; 371 | if (opt.scale) { 372 | const scaleStr = Array.isArray(opt.scale) ? opt.scale.join(':') : String(opt.scale); 373 | scale = `scale=${scaleStr}:flags=lanczos`; 374 | } 375 | const filterStr = [ fps, scale ].filter(Boolean).join(','); 376 | const filter1 = [ '-vf', filterStr + ',palettegen' ]; 377 | const filter2 = [ '-filter_complex', filterStr + '[x];[x][1:v]paletteuse' ]; 378 | 379 | const pass1Flags = [ '-y', ss, t, inputFPS, inputFlag, filter1, outputFPS, tmpFile ].filter(Boolean).reduce(flat, []); 380 | const pass2Flags = [ '-y', ss, t, inputFPS, inputFlag, '-i', tmpFile, filter2, '-f', 'gif', outputFPS, opt.output ].filter(Boolean).reduce(flat, []); 381 | let needsCleanup = true; 382 | 383 | function finish () { 384 | if (!needsCleanup) return; 385 | rimraf.sync(tmpFile); 386 | needsCleanup = false; 387 | } 388 | 389 | process.once('exit', () => finish()); 390 | 391 | try { 392 | logCommand(opt.cmd, pass1Flags); 393 | await spawnAsync(opt.cmd, pass1Flags); 394 | logCommand(opt.cmd, pass2Flags); 395 | await spawnAsync(opt.cmd, pass2Flags); 396 | finish(); 397 | } catch (err) { 398 | finish(); 399 | throw err; 400 | } 401 | } 402 | 403 | module.exports.start = (format) => { 404 | return module.exports.convert(module.exports.args({ format })) 405 | .catch(err => { 406 | const { message, stack } = getErrorDetails(err); 407 | console.error([ '', chalk.red(message), stack, '' ].join('\n')); 408 | }); 409 | }; 410 | 411 | function logCommand (cmd, args) { 412 | if (String(process.env.FFMPEG_DEBUG) === '1') { 413 | console.log(cmd, args.join(' ')); 414 | } 415 | } -------------------------------------------------------------------------------- /src/get-ffmpeg-cmd.js: -------------------------------------------------------------------------------- 1 | const resolveGlobal = require("resolve-global"); 2 | const { promisify } = require("util"); 3 | const resolve = promisify(require("resolve")); 4 | 5 | module.exports = getCommand; 6 | async function getCommand(opt = {}) { 7 | if (process.env.FFMPEG_PATH) { 8 | return process.env.FFMPEG_PATH; 9 | } 10 | 11 | // see if user has installed the npm bin 12 | const { 13 | moduleName = "@ffmpeg-installer/ffmpeg", 14 | cwd = process.cwd(), 15 | } = opt; 16 | 17 | // first resolve local version 18 | let modulePath; 19 | try { 20 | modulePath = await resolve(moduleName, { basedir: cwd }); 21 | } catch (err) { 22 | if (err.code !== "MODULE_NOT_FOUND") throw err; 23 | } 24 | 25 | // try to resolve to globally installed version 26 | if (!modulePath) { 27 | modulePath = resolveGlobal.silent(moduleName); 28 | } 29 | 30 | if (modulePath) { 31 | // if module resolved let's require it and use that 32 | const moduleInstance = require(modulePath); 33 | return moduleInstance.path.replace("app.asar", "app.asar.unpacked"); 34 | } else { 35 | // otherwise let's default to 'ffmpeg' 36 | console.warn( 37 | 'Warning: Could not find FFMPEG installed locally or globally, ' + 38 | 'defaulting to "ffmpeg" command. You might need to either specify ' + 39 | 'a FFMPEG_PATH env var, or install the following:\n npm install ' + 40 | '@ffmpeg-installer/ffmpeg --save' 41 | ); 42 | return 'ffmpeg'; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/html.js: -------------------------------------------------------------------------------- 1 | const concatStream = require('concat-stream'); 2 | const duplexer = require('duplexer2'); 3 | const through = require('through2'); 4 | const path = require('path'); 5 | const minify = require('html-minifier').minify; 6 | const fs = require('fs'); 7 | const maxstache = require('maxstache'); 8 | const { promisify } = require('util'); 9 | const writeFile = promisify(fs.writeFile); 10 | const readFile = promisify(fs.readFile); 11 | 12 | function transform (htmlData, opt = {}) { 13 | htmlData = maxstache(htmlData, { 14 | src: opt.src, 15 | title: opt.title || 'canvas-sketch', 16 | entry: opt.inline 17 | ? `` 18 | : `` 19 | }); 20 | if (opt.compress) { 21 | htmlData = minify(htmlData, { 22 | collapseBooleanAttributes: true, 23 | collapseWhitespace: true, 24 | decodeEntities: true, 25 | html5: true, 26 | minifyCSS: true, 27 | minifyJS: !opt.inline, 28 | removeAttributeQuotes: true, 29 | removeEmptyAttributes: true, 30 | removeOptionalTags: true, 31 | removeRedundantAttributes: true, 32 | removeScriptTypeAttributes: true, 33 | removeStyleLinkTypeAttributes: true, 34 | trimCustomFragments: true, 35 | useShortDoctype: true 36 | }); 37 | } 38 | return htmlData; 39 | } 40 | 41 | module.exports.stream = (opt = {}) => { 42 | const output = through(); 43 | return fs.createReadStream(opt.file).pipe(duplexer(concatStream(str => { 44 | str = str.toString(); 45 | str = transform(str, opt); 46 | output.end(str); 47 | }), output)); 48 | }; 49 | 50 | module.exports.read = async (opt = {}) => { 51 | const data = await readFile(opt.file, 'utf-8'); 52 | return transform(data, opt); 53 | }; 54 | -------------------------------------------------------------------------------- /src/ideas.txt: -------------------------------------------------------------------------------- 1 | - Input script -> output script 2 | - Input script -> output [ html, js ] scripts 3 | - Input script -> output html script (inlined) 4 | - Input scripts -> output scripts 5 | - Input scripts -> output [ html, js ] scripts for each 6 | - Input scripts -> output html scripts (inlined) for each 7 | - Input app + script(s) -> output script 8 | - Input app + script(s) -> output [ html, jsCommon, jsApp, ...scriptHmtml, ...scriptJS ] 9 | - Input app + script(s) -> output [ htmlInline, ...scriptInlines ] 10 | 11 | # Open a sketchbook of any non-node_modules sub folders 12 | canvas-sketchbook 13 | 14 | # Use a config of entries 15 | canvas-sketchbook config.json 16 | 17 | # Open a sketchbook of a specific file + folders 18 | canvas-sketchbook sketches/ src/other.js 19 | 20 | # Build current dir + sketches to dist/ 21 | canvas-sketchbook --build 22 | 23 | # HYPER idea - edit in dev mode 24 | canvas-sketchbook --code 25 | 26 | # Show code in sketchbook 27 | canvas-sketchbook --build --code 28 | 29 | # Build sketch folder as a book 30 | canvas-sketchbook sketches/ --build 31 | 32 | # Open a single sketch 33 | canvas-sketch src/index.js 34 | 35 | # Build the sketch to a JS + HTML pair in dist/ 36 | canvas-sketch src/index.js --build 37 | canvas-sketch src/index.js --build=app/ 38 | 39 | # Might give warnings if there are overlapping files... use -f to force 40 | canvas-sketch src/index.js --build=. 41 | 42 | # Use a custom HTML for the sketch 43 | canvas-sketch src/index.js --build --html=src/input.html 44 | 45 | 46 | my-sketches/ 47 | assets/ 48 | sketches/128.js 49 | 128.js 50 | 51 | canvas-sketch/ [building to .] 52 | lib/ 53 | docs/ 54 | website/ 55 | examples/ 56 | canvas-foobar.js 57 | 58 | consolidate cli + main repo into uber repo? 59 | 60 | NEGATIVE REASON: don't want to install all of canvas-sketch cli every time ! clunky 61 | 62 | SKETCH: 63 | - [Generate input file if --new] 64 | - Parse inputs (list of globs -> JS, JSON) 65 | - Other file types are filtered out and result in warnings 66 | - Convert JSON to input entries with { file: 'src/index.js', label: 'Generative Thing' } 67 | - Gather dependencies for each input 68 | - Also walk through local requires (not currently doing that) 69 | - Walk glslify deps as well 70 | - Install any necessary dependencies 71 | - DEVELOPMENT 72 | - Run budo on entry files 73 | - PRODUCTION 74 | - Write JS + HTML to dist/ 75 | - Use same name as input 76 | 77 | SKETHCBOOK: 78 | - [Generate input file if --new] 79 | - Parse inputs (list of globs -> JS, JSON) 80 | - Other file types are filtered out and result in warnings 81 | - Convert JSON to input entries with { file: 'src/index.js', label: 'Generative Thing' } 82 | - Gather dependencies for each input 83 | - Also walk through local requires (not currently doing that) 84 | - Walk glslify deps as well 85 | - Install any necessary dependencies 86 | - DEVELOPMENT 87 | - Local server middleware will see `${input.src}.js` and serve it factored 88 | - Local server middleware will see `${input.src}.html` and serve HTML 89 | - Clicking "Add New Sketch" will send WebSocket event to add a --new generated sketch 90 | - Right-click + "Delete Sketch" + Confirm will send WebSocket event to remove the sketch file 91 | - PRODUCTION 92 | - Write all files to dist/ 93 | - Generate "common" JS bundle shared across app + all files 94 | - Generate "app" JS bundle just used for the main app 95 | - 96 | 97 | 98 | ASSETS?!? -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const path = require('path'); 3 | const budo = require('budo'); 4 | const defined = require('defined'); 5 | const parseArgs = require('subarg'); 6 | const rightNow = require('right-now'); 7 | const prettyBytes = require('pretty-bytes'); 8 | const prettyMs = require('pretty-ms'); 9 | const downloads = require('./downloads-folder'); 10 | const getStdin = require('get-stdin'); 11 | const esmify = require('esmify'); 12 | const fs = require('fs'); 13 | const chalk = require('chalk'); 14 | const { promisify } = require('util'); 15 | const { generateFileName, readPackage, isCanvasSketchPackage } = require('./util'); 16 | const mkdirp = promisify(require('mkdirp')); 17 | const writeFile = promisify(fs.writeFile); 18 | const install = require('./install'); 19 | const resolve = require('resolve'); 20 | const browserifyFromArgs = require('browserify/bin/args'); 21 | const createMiddleware = require('./middleware'); 22 | const { createLogger, getErrorDetails } = require('./logger'); 23 | const html = require('./html'); 24 | const terser = require('terser'); 25 | const SourceMapUtil = require('convert-source-map'); 26 | const { EventEmitter } = require('events'); 27 | const pluginEnv = require('./plugins/plugin-env'); 28 | const pluginResolve = require('./plugins/plugin-resolve'); 29 | const pluginGLSL = require('./plugins/plugin-glsl'); 30 | // const transformInstaller = require('./plugins/transform-installer'); 31 | 32 | const DEFAULT_GENERATED_FILENAME = '_generated.js'; 33 | 34 | const bundleAsync = (bundler) => { 35 | return new Promise((resolve, reject) => { 36 | bundler.bundle((err, src) => { 37 | if (err) reject(err); 38 | else resolve(src); 39 | }); 40 | }); 41 | }; 42 | 43 | const start = async (args, overrides = {}) => { 44 | const argv = parseArgs(args, { 45 | string: ['template'], 46 | boolean: [ 47 | 'hot', 48 | 'open', 49 | 'force', 50 | 'pushstate', 51 | 'install', 52 | 'quiet', 53 | 'build', 54 | 'version', 55 | 'inline', 56 | 'watching', 57 | 'client', 58 | 'help' 59 | ], 60 | alias: { 61 | sourceMap: 'source-map', 62 | version: 'v', 63 | port: 'p', 64 | pushstate: 'P', 65 | build: 'b', 66 | dir: 'd', 67 | open: 'o', 68 | install: 'I', 69 | force: 'f', 70 | template: 't', 71 | stream: 'S', 72 | new: 'n', 73 | 'help': 'h' 74 | }, 75 | '--': true, 76 | default: { 77 | watching: true, 78 | install: true, 79 | client: true, 80 | template: 'default' 81 | } 82 | }); 83 | 84 | // Merge in user options 85 | Object.assign(argv, overrides); 86 | 87 | // Handle stream subargs 88 | if (typeof argv.stream === 'string') { 89 | argv.stream = { 90 | format: argv.stream 91 | }; 92 | } else if (argv.stream === true) { 93 | argv.stream = { 94 | format: 'mp4' 95 | }; 96 | } else if (argv.stream && typeof argv.stream === 'object') { 97 | if (argv.stream._) { 98 | if (!argv.stream.format) argv.stream.format = argv.stream._; 99 | delete argv.stream._; 100 | } 101 | } 102 | 103 | // Handle array -> single string 104 | if (argv.stream) { 105 | if (Array.isArray(argv.stream.format)) { 106 | argv.stream.format = argv.stream.format[0] || 'mp4'; 107 | } 108 | } 109 | 110 | if (argv.help) { 111 | const help = path.join(__dirname, '..', 'bin', 'help.txt'); 112 | fs.createReadStream(help) 113 | .pipe(process.stdout); 114 | return null; 115 | } 116 | 117 | if (argv.version) { 118 | console.log(require('../package.json').version); 119 | process.exit(0); 120 | } 121 | 122 | const templateDirectory = 'templates'; 123 | const sketchDirectory = 'sketches'; 124 | const defaultDir = '.'; 125 | 126 | const cwd = argv.cwd || process.cwd(); 127 | 128 | let dir; 129 | if (argv.dir) { 130 | dir = path.isAbsolute(argv.dir) ? argv.dir : path.resolve(cwd, argv.dir); 131 | } else { 132 | dir = path.resolve(cwd, defaultDir); 133 | } 134 | 135 | const templateHtmlFile = path.resolve(__dirname, 'templates/index.html'); 136 | let htmlFile; 137 | if (argv.html) { 138 | htmlFile = path.isAbsolute(argv.html) ? path.resolve(argv.html) : path.resolve(cwd, argv.html); 139 | } else { 140 | htmlFile = templateHtmlFile; 141 | } 142 | 143 | const logger = createLogger(argv); 144 | let opt; 145 | try { 146 | opt = await prepare(logger); 147 | } catch (err) { 148 | throw err; 149 | } 150 | 151 | const stripJSExt = (name) => { 152 | return /\.(ts|js|mjs|es|jsx|tsx|es6)$/i.test(name) ? path.basename(name, path.extname(name)) : name; 153 | }; 154 | 155 | const fileName = (opt.name && typeof opt.name === 'string') ? opt.name : path.basename(opt.entry); 156 | 157 | const fileNameBase = stripJSExt(fileName); 158 | const fileNameJS = opt.js || `${fileNameBase}.js`; 159 | 160 | let jsUrl = opt.js || encodeURIComponent(fileNameJS); 161 | const htmlOpts = { file: htmlFile, src: jsUrl, title: opt.title || fileName }; 162 | 163 | if (opt.build) { 164 | const compressJS = argv.compress !== 'html' && argv.compress !== false; 165 | const compressHTML = argv.compress !== 'js' && argv.compress !== false; 166 | 167 | const jsOutFile = path.resolve(dir, fileNameJS); 168 | if (jsOutFile === opt.entry) { 169 | throw new Error(`The input and ouput JS files are the same: ${chalk.bold(path.relative(cwd, jsOutFile))}`); 170 | } 171 | 172 | const htmlOutFile = path.resolve(dir, `${fileNameBase}.html`); 173 | if (htmlOutFile === htmlOpts.file) { 174 | throw new Error(`The input and ouput HTML files are the same: ${chalk.bold(path.relative(cwd, htmlOpts.file))}`); 175 | } 176 | 177 | // Start building our static contents 178 | let timeStart = Date.now(); 179 | logger.log('Building...'); 180 | 181 | const inline = opt.inline; 182 | 183 | let sourceMapOption = opt.sourceMap; 184 | // parse CLI string 185 | if (sourceMapOption === 'true' || sourceMapOption === 'false') { 186 | sourceMapOption = sourceMapOption === 'true'; 187 | } 188 | if (sourceMapOption == null || sourceMapOption === 'auto' || sourceMapOption === true) { 189 | sourceMapOption = true; 190 | // By default, sourceMap: true will also be inlined on --inline 191 | // But user can still override with {sourceMap: 'external'} 192 | if (inline) { 193 | sourceMapOption = 'inline'; 194 | } 195 | } 196 | 197 | // Create bundler from CLI options 198 | let debug = typeof opt.debug === 'boolean' ? opt.debug : true; 199 | if (sourceMapOption === false) debug = false; 200 | const bundler = browserifyFromArgs(opt.browserifyArgs, { 201 | debug, 202 | entries: opt.entry 203 | }); 204 | 205 | // First, make sure our output (public) dir exists 206 | await mkdirp(dir); 207 | 208 | // Now bundle up our code into a string 209 | let buffer; 210 | try { 211 | buffer = await bundleAsync(bundler); 212 | } catch (err) { 213 | throw err; 214 | } 215 | let code = buffer.toString(); 216 | 217 | const sourceMapFile = `${jsOutFile}.map`; 218 | 219 | // Get initial source map from browserify 220 | let sourceMapJSON; 221 | const sourceMapConverter = SourceMapUtil.fromSource(code); 222 | if (sourceMapConverter) sourceMapJSON = sourceMapConverter.toJSON(); 223 | 224 | if (sourceMapOption && !sourceMapJSON) { 225 | sourceMapOption = false; 226 | // Maybe it would be good to warn why a .map file is not generated? Or maybe too heavy handed... 227 | // console.warn('Note: Source map not generated because browserify did not emit an inline source map.\nTo hide this warning, use {sourceMap: false} or --source-map=false'); 228 | } 229 | 230 | if (compressJS) { 231 | // Strip browserify inline source map if it exists 232 | code = SourceMapUtil.removeComments(code); 233 | 234 | try { 235 | const terserOpt = {}; 236 | terserOpt[path.basename(jsOutFile)] = code; 237 | 238 | const sourceMap = sourceMapOption && debug && sourceMapJSON ? { 239 | content: sourceMapJSON, 240 | url: path.basename(sourceMapFile) 241 | } : false; 242 | 243 | const terserResult = await terser.minify(terserOpt, { 244 | sourceMap, 245 | output: { comments: false }, 246 | compress: { 247 | keep_infinity: true, 248 | pure_getters: true 249 | }, 250 | // warnings: true, 251 | ecma: 5, 252 | toplevel: false, 253 | mangle: { 254 | properties: false 255 | } 256 | }); 257 | if (terserResult.error) { 258 | terserResult.error.originalSourceCode = code; 259 | throw terserResult.error; 260 | } 261 | code = terserResult.code; 262 | if (sourceMapOption) sourceMapJSON = terserResult.map; 263 | } catch (err) { 264 | throw err; 265 | } 266 | } 267 | 268 | // strip any subsequently added source maps 269 | code = SourceMapUtil.removeComments(code); 270 | code = SourceMapUtil.removeMapFileComments(code); 271 | 272 | // now add them back in as per our options 273 | if (sourceMapOption && sourceMapJSON && debug) { 274 | let relSrcMapFile = path.relative(dir, sourceMapFile); 275 | // if (!path.isAbsolute(relSrcMapFile)) { 276 | // relSrcMapFile = `./${encodeURIComponent(relSrcMapFile)}`; 277 | // } 278 | const isInline = sourceMapOption === 'inline'; 279 | const comment = isInline 280 | ? SourceMapUtil.fromJSON(sourceMapJSON).toComment() 281 | : SourceMapUtil.generateMapFileComment(relSrcMapFile); 282 | if (!code.endsWith('\n')) code += '\n'; 283 | code += comment + '\n'; 284 | } 285 | 286 | // In --stdout mode, just output the code 287 | if (opt.stdout) { 288 | throw new Error('--stdout is not yet supported'); 289 | } else { 290 | // A util to log the output of a file 291 | const logFile = (type, file, data) => { 292 | const bytes = chalk.dim(`(${prettyBytes(data.length)})`); 293 | logger.log(`${type} → ${chalk.bold(path.relative(cwd, file))} ${bytes}`, { leadingSpace: false }); 294 | }; 295 | 296 | // Read the templated HTML, transform it and write it out 297 | const htmlData = await html.read(Object.assign({}, htmlOpts, { 298 | inline, 299 | code, 300 | compress: compressHTML 301 | })); 302 | await writeFile(htmlOutFile, htmlData); 303 | logFile('HTML ', htmlOutFile, htmlData); 304 | 305 | // Write bundled JS 306 | if (!inline) { 307 | await writeFile(jsOutFile, code); 308 | logFile('JS ', jsOutFile, code); 309 | } 310 | 311 | if (sourceMapOption !== false && sourceMapOption !== 'inline' && sourceMapJSON) { 312 | await writeFile(sourceMapFile, sourceMapJSON); 313 | logFile('SrcMap', sourceMapFile, sourceMapJSON); 314 | } 315 | 316 | const ms = (Date.now() - timeStart); 317 | logger.log(`Finished in ${chalk.magenta(prettyMs(ms))}`, { leadingSpace: false }); 318 | logger.pad(); 319 | } 320 | 321 | return null; 322 | } else { 323 | // pad the previous logs if necessary 324 | logger.pad(); 325 | 326 | const browserifyArgs = opt.browserifyArgs; 327 | const clientMiddleware = createMiddleware(opt); 328 | 329 | const hotReloading = opt.hot; 330 | const entries = [ 331 | // Could find a cleaner way to pass down props 332 | // to client scripts... 333 | opt.output ? require.resolve('./instrumentation/client-enable-output.js') : undefined, 334 | hotReloading ? require.resolve('./instrumentation/client-enable-hot.js') : undefined, 335 | opt.client !== false ? require.resolve('./instrumentation/client.js') : undefined, 336 | opt.entry 337 | ].filter(Boolean); 338 | 339 | const applyReload = (app, wss) => { 340 | // Because some editors & operating systems do atomic updates 341 | // very quickly together on file save, you can end up with duplicate 342 | // file change events from chokidar in some cases. We guard against 343 | // this by not evaluating duplicate code that is run within a fraction 344 | // of a second. 345 | const chokidarThreshold = 150; 346 | 347 | let lastTime = Date.now(); 348 | let chokidarDelta = Infinity; 349 | 350 | var lastBundle; 351 | var hasError = false; 352 | 353 | // Tell the active instances whether to enable or disable hot reloading 354 | wss.on('connection', (socket) => { 355 | socket.send(JSON.stringify({ event: 'hot-reload', enabled: hotReloading })); 356 | }); 357 | 358 | // Hot reloading reacts on update, after bundle is finished 359 | app.on('update', (code) => { 360 | lastTime = rightNow(); 361 | 362 | if (hotReloading) { 363 | code = code.toString(); 364 | if (chokidarDelta < chokidarThreshold && code === lastBundle) { 365 | // We only do this chokidar guard when the bundle is the same. 366 | // If the bundle is different, we definitely want to apply the changes! 367 | return; 368 | } 369 | 370 | wss.clients.forEach(socket => { 371 | socket.send(JSON.stringify({ 372 | event: 'eval', 373 | src: jsUrl, 374 | error: hasError, 375 | code 376 | })); 377 | }); 378 | lastBundle = code; 379 | } 380 | }); 381 | 382 | // Non-hot reloading reacts on pending, before bundle starts updating 383 | // This makes the experience feel more instant 384 | app.on('pending', () => { 385 | const now = rightNow(); 386 | chokidarDelta = now - lastTime; 387 | if (!hotReloading && chokidarDelta > chokidarThreshold) { 388 | // We avoid duplicate reload events here with the chokidar threshold 389 | app.reload(); 390 | } 391 | }); 392 | 393 | app.on('pending', () => { 394 | hasError = false; 395 | }); 396 | 397 | app.on('bundle-error', () => { 398 | hasError = true; 399 | }); 400 | }; 401 | 402 | const app = budo(entries, { 403 | browserifyArgs, 404 | open: argv.open, 405 | serve: jsUrl, 406 | ssl: argv.https, 407 | port: argv.port || 9966, 408 | pushstate: argv.pushstate, 409 | middleware: clientMiddleware.middleware, 410 | ignoreLog: clientMiddleware.ignoreLog, 411 | forceDefaultIndex: true, 412 | defaultIndex: () => html.stream(htmlOpts), 413 | dir, 414 | stream: argv.quiet ? null : process.stdout 415 | }) 416 | .on('watch', (ev, file) => { 417 | app.reload(file); 418 | }) 419 | .on('connect', ev => { 420 | // Here we do some things like notify the clients that a module is being 421 | // installed. 422 | const wss = ev.webSocketServer; 423 | if (wss) { 424 | const installEvents = ['install-start', 'install-end']; 425 | installEvents.forEach(key => { 426 | opt.installer.on(key, ({ modules }) => { 427 | app.error(key === 'install-start' 428 | ? `Installing modules from npm: ${modules.join(', ')}` 429 | : `Reloading...`); 430 | wss.clients.forEach(socket => { 431 | socket.send(JSON.stringify({ event: key })); 432 | }); 433 | }); 434 | }); 435 | applyReload(app, wss); 436 | } 437 | }); 438 | 439 | if (argv.watching !== false) { 440 | app.live().watch(); 441 | } 442 | 443 | return app; 444 | } 445 | 446 | async function prepare(logger) { 447 | // Write a new package, but first check for collision 448 | const dirName = path.basename(cwd); 449 | if (dirName === 'canvas-sketch') { 450 | const pkg = await readPackage({ cwd }, true); 451 | if (!pkg || !isCanvasSketchPackage(pkg)) { 452 | throw new Error(`Your folder name is ${chalk.bold('canvas-sketch')} which may lead to conflicts when using this tool. Please choose another folder name and run the command again.`); 453 | } 454 | } 455 | 456 | if (argv._.length > 1) { 457 | throw new Error('Currently only one entry is supported.\n\nExample usage:\n canvas-sketch src/index.js'); 458 | } 459 | 460 | let entry = argv._[0]; 461 | delete argv._; 462 | const browserifyArgs = argv['--'] || []; 463 | delete argv['--']; 464 | 465 | let entrySrc; 466 | if (argv.new) { 467 | const prefix = typeof argv.new === 'string' ? argv.new : undefined; 468 | let filepath; 469 | if (entry) { 470 | filepath = path.isAbsolute(entry) ? path.resolve(entry) : path.resolve(cwd, entry); 471 | 472 | // ensure a file extension is present. If not, automatically add .js 473 | if (!path.extname(entry)) { 474 | filepath = `${filepath}.js`; 475 | } 476 | } else { 477 | filepath = path.resolve(cwd, sketchDirectory, generateFileName(prefix)); 478 | } 479 | 480 | if (!argv.force && fs.existsSync(filepath)) { 481 | throw new Error(`The file already exists: ${path.relative(cwd, filepath)} (use -f to overwrite)`); 482 | } 483 | 484 | // Ensure the folder path exists 485 | const fileDir = path.dirname(filepath); 486 | await mkdirp(fileDir); 487 | 488 | // Get stdin for piping 489 | const stdin = (await getStdin()).trim(); 490 | 491 | if (stdin && (!argv.template || argv.template === 'default')) { 492 | // Allow the user to pass in piped code 493 | entrySrc = stdin; 494 | } else { 495 | let templateFile; 496 | if (/^[\\/.]/.test(argv.template)) { 497 | templateFile = path.isAbsolute(argv.template) 498 | ? argv.template 499 | : path.resolve(cwd, argv.template); 500 | if (!fs.existsSync(templateFile)) { 501 | throw new Error(`Couldn't find a template at ${argv.template}`); 502 | } 503 | } else { 504 | templateFile = path.resolve(__dirname, templateDirectory, `${argv.template}.js`); 505 | if (!fs.existsSync(templateFile)) { 506 | throw new Error(`Couldn't find a template by the key ${argv.template}`); 507 | } 508 | } 509 | 510 | try { 511 | entrySrc = fs.readFileSync(templateFile, 'utf-8'); 512 | } catch (err) { 513 | throw new Error(`Error while reading the template ${argv.template}`); 514 | } 515 | } 516 | 517 | logger.log(`Writing file: ${chalk.bold(path.relative(cwd, filepath))}`); 518 | fs.writeFileSync(filepath, entrySrc); 519 | entry = filepath; 520 | } 521 | 522 | if (!entry) { 523 | logger.error('No entry file specified!', `Example usage:\n canvas-sketch src/index.js\n canvas-sketch --new --template=regl`); 524 | process.exit(1); 525 | } 526 | 527 | // Read source code 528 | if (!entrySrc) { 529 | try { 530 | const entryPath = /^[.\//]/.test(entry) ? entry : ('./' + entry); 531 | entry = resolve.sync(entryPath, { basedir: cwd }); 532 | } catch (err) { 533 | logger.error(`Cannot find file "${chalk.bold(entry)}"`); 534 | logger.pad(); 535 | process.exit(1); 536 | } 537 | 538 | try { 539 | entrySrc = fs.readFileSync(entry, 'utf-8'); 540 | } catch (err) { 541 | logger.error(`Cannot read entry file "${chalk.bold(path.relative(cwd, entry))}"`, err); 542 | logger.pad(); 543 | process.exit(1); 544 | } 545 | } 546 | 547 | // Install dependencies from the template if needed 548 | // Note: this was a regretful decision! A little difficult to remove entirely 549 | // as one of the primary entrypoints is `canvas-sketch sketch.js --new` which requires 550 | // installation of canvas-sketch itself. In future it would be good to get more granular; 551 | // perhaps auto-installing canvas-sketch but no other libraries unless specified. 552 | const shouldInstall = argv.install !== false; 553 | if (shouldInstall) { 554 | try { 555 | await install(entry, { entrySrc, logger, cwd }); 556 | } catch (err) { 557 | console.error(err.toString()); 558 | } 559 | } 560 | 561 | let output = typeof argv.output !== 'undefined' ? argv.output : true; 562 | if (typeof output === 'string' && /^(true|false)$/.test(output)) { 563 | // handle string argv parsing 564 | output = output === 'true'; 565 | } 566 | if (output == null || output === true) { 567 | // Default to downloads 568 | output = downloads({ logger }); 569 | } else if (output === '.') { 570 | // Accept '.' as current dir 571 | output = cwd; 572 | } 573 | 574 | const mode = defined(argv.mode, argv.build ? 'production' : 'development'); 575 | const hot = Boolean(argv.hot); 576 | const params = Object.assign({}, argv, { 577 | mode, 578 | browserifyArgs, 579 | extensions: pluginGLSL.extensions, 580 | output, 581 | logger, 582 | hot, 583 | entry, 584 | cwd, 585 | installer: new EventEmitter() 586 | }); 587 | 588 | browserifyArgs.push( 589 | // Add in ESM support 590 | '-p', (bundler, opts) => { 591 | return esmify(bundler, Object.assign({}, opts, { 592 | // Disable "module" field since it is brutally annoying :( 593 | // Basically it changes the way CommonJS-authored code needs to 594 | // be written, forcing authors to update their code paths to use: 595 | // require('blah').default 596 | // The added benefit of tree-shaking ES Modules isn't even used here (no rollup/webpack) 597 | // so we will just discard it altogether for a cleaner developer & user experience. 598 | mainFields: ['browser', 'main'], 599 | // This is a bit frustrating, as well. Babel-ifying the entire node_modules 600 | // tree is extremely slow, and only fixes a few problematic modules 601 | // that have decided to publish with ESM, which isn't even standard yet! 602 | // So, we will only support ESM in local code for canvas-sketch. 603 | nodeModules: false, 604 | logFile: argv.logFile 605 | })); 606 | }, 607 | '-g', pluginGLSL(params), 608 | // Add in glslify and make it resolve to here 609 | '-g', require.resolve('glslify'), 610 | // A plugin that handles resolving some modules to this CLI tool 611 | '-p', pluginResolve(params), 612 | // Also add in some envify tools 613 | '-p', pluginEnv(params) 614 | ); 615 | 616 | // TODO: Figure out a nice way to install automatically 617 | // if (argv.install !== false) { 618 | // browserifyArgs.push('-t', transformInstaller(params)); 619 | // } 620 | 621 | return params; 622 | } 623 | }; 624 | 625 | module.exports = start; 626 | 627 | if (!module.parent) { 628 | module.exports(process.argv.slice(2)).catch(err => { 629 | const { message, stack } = getErrorDetails(err); 630 | if (err instanceof SyntaxError || err.name === 'SyntaxError') { 631 | if (typeof err.line === 'number' && isFinite(err.line) && 632 | typeof err.col === 'number' && isFinite(err.col)) { 633 | const { 634 | line, 635 | col, 636 | originalSourceCode, 637 | message 638 | } = err; 639 | const formattedSrc = originalSourceCode ? formatSyntaxError(originalSourceCode, line, col) : ''; 640 | console.error([ 641 | '', 642 | chalk.red(`SyntaxError: ${message}`), 643 | `At line ${chalk.magenta(line)} and column ${chalk.magenta(col)} of generated bundle`, 644 | formattedSrc, 645 | '' 646 | ].join('\n')); 647 | } else { 648 | console.error(`\n${err.toString()}\n`); 649 | } 650 | } else if (stack) { 651 | console.error([ 652 | '', 653 | chalk.red(message), 654 | '', 655 | ` ${stack.trim().split('\n').slice(0, 10).join('\n')}`, 656 | '' 657 | ].join('\n')); 658 | } else { 659 | console.error(err); 660 | } 661 | }); 662 | } 663 | 664 | function formatSyntaxError (code, line, col, buffer = 2) { 665 | const lineIndex = line - 1; 666 | let lines = code.split('\n').map((line, index) => { 667 | return { line, index }; 668 | }); 669 | const minLine = Math.max(0, lineIndex - buffer); 670 | const maxLine = Math.min(lines.length, lineIndex + buffer + 1); 671 | const msg = [ 672 | ' ...', 673 | ...lines.slice(minLine, maxLine).map(line => { 674 | const prefix = ` ${line.index + 1}: `; 675 | const lines = [ 676 | `${prefix}${line.line}` 677 | ]; 678 | if (lineIndex === line.index) { 679 | const cursor = chalk.bold(chalk.red('^')); 680 | lines[0] = chalk.red(lines[0]); 681 | const colCount = Math.max(0, col - 1); 682 | const prefixSpaces = repeatCharacters(colCount + prefix.length, ' '); 683 | lines.push(`${prefixSpaces}${cursor}`); 684 | } 685 | return lines.join('\n'); 686 | }), 687 | ' ...' 688 | ].join('\n'); 689 | return msg; 690 | } 691 | 692 | function repeatCharacters (count, char = ' ') { 693 | return Array.from(new Array(count)).map(() => char).join(''); 694 | } 695 | -------------------------------------------------------------------------------- /src/install.js: -------------------------------------------------------------------------------- 1 | const { promisify } = require('util'); 2 | const { isCanvasSketchPackage, readPackage } = require('./util'); 3 | const isBuiltin = require('is-builtin-module'); 4 | const packageName = require('require-package-name'); 5 | const semver = require('semver'); 6 | const chalk = require('chalk'); 7 | const { exec } = require('child_process'); 8 | const path = require('path'); 9 | const fs = require('fs'); 10 | const walkDeps = require('./walk-local-deps'); 11 | const defined = require('defined'); 12 | 13 | const execAsync = promisify(exec); 14 | 15 | const DEFAULT_IGNORES = [ 'glslify' ]; 16 | 17 | const writePackageIfNeeded = async (opt = {}) => { 18 | const logger = opt.logger; 19 | const cwd = opt.cwd || process.cwd(); 20 | if (fs.existsSync(path.resolve(cwd, 'package.json'))) return; 21 | 22 | if (logger) { 23 | logger.log(`Generating default "${chalk.bold('package.json')}" file`); 24 | } 25 | const { stderr } = await execAsync('npm init -y'); 26 | // It's kinda noisy to print this for average users, and a bit scary looking 27 | // if (stdout) console.log(stdout.trim()); 28 | if (stderr) console.error(stderr.trim()); 29 | }; 30 | 31 | // Install npm modules from a sketch template 32 | module.exports = async function (entry, opt = {}) { 33 | const logger = opt.logger; 34 | const ignore = DEFAULT_IGNORES.concat(opt.ignore).filter(Boolean); 35 | const maxDepth = defined(opt.maxDepth, Infinity); 36 | const entrySrc = opt.entrySrc; 37 | 38 | // walk the file and its local dependency tree 39 | let requires; 40 | try { 41 | requires = await walkDeps(entry, { maxDepth, entrySrc }); 42 | } catch (err) { 43 | throw err; 44 | } 45 | 46 | let dependencies = requires 47 | .filter(req => !/^[./\\/]/.test(req) && !ignore.includes(req)) 48 | .map(req => packageName(req)) 49 | .filter(req => !isBuiltin(req)) 50 | .filter((item, i, list) => list.indexOf(item) === i); 51 | 52 | // nothing to install 53 | if (dependencies.length === 0) return; 54 | 55 | // write package.json first to ensure deps are installed nicely 56 | await writePackageIfNeeded(opt); 57 | 58 | // get package JSON 59 | const pkg = await readPackage(); 60 | 61 | const currentDeps = Object.keys(pkg.dependencies || {}).concat(Object.keys(pkg.devDependencies || {})); 62 | let filtered = dependencies.filter(dep => !currentDeps.includes(dep)); 63 | 64 | // patch to fix ThreeJS :'( 65 | // we are locked on an earlier version of ThreeJS unless 66 | // canvas-sketch is overhauled to esbuild or the esm loader is fixed 67 | const threeIdx = dependencies.indexOf('three'); 68 | if (threeIdx >= 0) { 69 | const fixedVers = '0.147.0'; 70 | const toInstall = `three@${fixedVers}`; 71 | 72 | 73 | const hasThreeDep = currentDeps.includes('three'); 74 | if (!hasThreeDep) { 75 | // Case A: User does not have ThreeJS already in package.json 76 | // So we can just add the versioned tag to installation 77 | filtered = filtered.map(f => f === 'three' ? toInstall : f); 78 | 79 | // filtered.push(toInstall) 80 | console.warn(chalk.red( 81 | ` 82 | ~~~~~~~ NOTE ~~~~~~~ 83 | canvas-sketch currently only supports older versions 84 | of ThreeJS, the CLI will auto-install version ${fixedVers}. 85 | ~~~~~~~~~~~~~~~~~~~~` 86 | )); 87 | } else { 88 | // Case B: User does have it in package.json 89 | // Let's compare versions to see if we should warn them or not 90 | const deps = pkg.dependencies || {}; 91 | const devDeps = pkg.devDependencies || {}; 92 | const threeDep = 'three' in deps ? deps['three'] : devDeps['three']; 93 | if (threeDep && !semver.intersects(`<=${fixedVers}`, threeDep)) { 94 | console.warn(chalk.red( 95 | ` 96 | ~~~~~~~ NOTE ~~~~~~~ 97 | canvas-sketch currently only supports older versions 98 | of ThreeJS, and this package specifies a higher version number. 99 | You can re-install ${toInstall} to down-grade to a working version: 100 | 101 | npm install ${toInstall} --save-dev 102 | 103 | ~~~~~~~~~~~~~~~~~~~~` 104 | )); 105 | } else { 106 | // A-OK - user is on the right version range 107 | } 108 | } 109 | } 110 | 111 | let key = 'dependencies'; 112 | const canvasSketchModule = 'canvas-sketch'; 113 | if (isCanvasSketchPackage(pkg)) { 114 | // Not sure it's really useful to warn the user of this 115 | if (logger) logger.log(`Note: Not installing ${chalk.bold(canvasSketchModule)} since we are already in its repository`); 116 | 117 | filtered = filtered.filter(dep => dep !== canvasSketchModule); 118 | key = 'devDependencies'; 119 | } 120 | 121 | // Only install if needed 122 | if (filtered.length > 0) { 123 | const obj = { stdio: 'inherit', shell: true, audit: false, fund: false, silent: true }; 124 | obj[key] = filtered; 125 | 126 | if (logger) { 127 | if (key === 'devDependencies') { 128 | logger.log(`Note: Installing into devDependencies since we are in ${chalk.bold(canvasSketchModule)} repository`); 129 | } 130 | logger.log(`Installing ${key}:\n ${chalk.bold(filtered.join(', '))}`); 131 | logger.pad(); 132 | } 133 | 134 | if (opt.installer) { 135 | opt.installer.emit('install-start', { entry, modules: filtered }); 136 | } 137 | try { 138 | const installIfNeededCB = require('install-if-needed') 139 | const installIfNeeded = promisify(installIfNeededCB); 140 | await installIfNeeded(obj); 141 | if (opt.installer) { 142 | opt.installer.emit('install-end', { entry, modules: filtered }); 143 | } 144 | } catch (err) { 145 | if (opt.installer) { 146 | opt.installer.emit('install-end', { entry, modules: filtered, err }); 147 | } 148 | throw err; 149 | } 150 | } 151 | }; 152 | -------------------------------------------------------------------------------- /src/instrumentation/client-enable-hot.js: -------------------------------------------------------------------------------- 1 | // Mark hot reloading as enabled 2 | window['canvas-sketch-cli'] = window['canvas-sketch-cli'] || {}; 3 | window['canvas-sketch-cli'].hot = true; 4 | -------------------------------------------------------------------------------- /src/instrumentation/client-enable-output.js: -------------------------------------------------------------------------------- 1 | // Mark output/export as enabled for the client API scripts. 2 | window['canvas-sketch-cli'] = window['canvas-sketch-cli'] || {}; 3 | window['canvas-sketch-cli'].output = true; 4 | -------------------------------------------------------------------------------- /src/instrumentation/client.js: -------------------------------------------------------------------------------- 1 | const NAMESPACE = 'canvas-sketch-cli'; 2 | 3 | // Grab the CLI namespace 4 | window[NAMESPACE] = window[NAMESPACE] || {}; 5 | 6 | if (!window[NAMESPACE].initialized) { 7 | initialize(); 8 | } 9 | 10 | function initialize () { 11 | // Awaiting enable/disable event 12 | window[NAMESPACE].liveReloadEnabled = undefined; 13 | window[NAMESPACE].initialized = true; 14 | 15 | const defaultPostOptions = { 16 | method: 'POST', 17 | cache: 'no-cache', 18 | credentials: 'same-origin' 19 | }; 20 | 21 | // File saving utility 22 | window[NAMESPACE].saveBlob = (blob, opts) => { 23 | opts = opts || {}; 24 | 25 | const form = new window.FormData(); 26 | form.append('file', blob, opts.filename); 27 | return window.fetch('/canvas-sketch-cli/saveBlob', Object.assign({}, defaultPostOptions, { 28 | body: form 29 | })).then(res => { 30 | if (res.status === 200) { 31 | return res.json(); 32 | } else { 33 | return res.text().then(text => { 34 | throw new Error(text); 35 | }); 36 | } 37 | }).catch(err => { 38 | // Some issue, just bail out and return nil hash 39 | console.warn(`There was a problem exporting ${opts.filename}`); 40 | console.error(err); 41 | return undefined; 42 | }); 43 | }; 44 | 45 | const stream = (url, opts) => { 46 | opts = opts || {}; 47 | 48 | return window.fetch(url, Object.assign({}, defaultPostOptions, { 49 | headers: { 50 | 'Content-Type': 'application/json' 51 | }, 52 | body: JSON.stringify({ 53 | save: opts.save, 54 | encoding: opts.encoding, 55 | timeStamp: opts.timeStamp, 56 | fps: opts.fps, 57 | filename: opts.filename 58 | }) 59 | })) 60 | .then(res => { 61 | if (res.status === 200) { 62 | return res.json(); 63 | } else { 64 | return res.text().then(text => { 65 | throw new Error(text); 66 | }); 67 | } 68 | }).catch(err => { 69 | // Some issue, just bail out and return nil hash 70 | console.warn(`There was a problem starting the stream export`); 71 | console.error(err); 72 | return undefined; 73 | }); 74 | }; 75 | 76 | // File streaming utility 77 | window[NAMESPACE].streamStart = (opts) => { 78 | return stream('/canvas-sketch-cli/stream-start', opts); 79 | }; 80 | 81 | window[NAMESPACE].streamEnd = (opts) => { 82 | return stream('/canvas-sketch-cli/stream-end', opts); 83 | }; 84 | 85 | // git commit utility 86 | window[NAMESPACE].commit = () => { 87 | return window.fetch('/canvas-sketch-cli/commit', defaultPostOptions) 88 | .then(resp => resp.json()) 89 | .then(result => { 90 | if (result.error) { 91 | if (result.error.toLowerCase().includes('not a git repository')) { 92 | console.warn(`Warning: ${result.error}`); 93 | return null; 94 | } else { 95 | throw new Error(result.error); 96 | } 97 | } 98 | // Notify user of changes 99 | console.log(result.changed 100 | ? `[git] ${result.hash} Committed changes` 101 | : `[git] ${result.hash} Nothing changed`); 102 | return result.hash; 103 | }) 104 | .catch(err => { 105 | // Some issue, just bail out and return nil hash 106 | console.warn('Could not commit changes and fetch hash'); 107 | console.error(err); 108 | return undefined; 109 | }); 110 | }; 111 | 112 | if ('budo-livereload' in window) { 113 | const client = window['budo-livereload']; 114 | client.listen(data => { 115 | if (data.event === 'hot-reload') { 116 | setupLiveReload(data.enabled); 117 | } 118 | }); 119 | 120 | // On first load, check to see if we should setup live reload or not 121 | if (window[NAMESPACE].hot) { 122 | setupLiveReload(true); 123 | } else { 124 | setupLiveReload(false); 125 | } 126 | } 127 | } 128 | 129 | function setupLiveReload (isEnabled) { 130 | const previousState = window[NAMESPACE].liveReloadEnabled; 131 | if (typeof previousState !== 'undefined' && isEnabled !== previousState) { 132 | // We need to reload the page to ensure the new sketch function is 133 | // named for hot reloading, and/or cleaned up after hot reloading is disabled 134 | window.location.reload(true); 135 | return; 136 | } 137 | 138 | if (isEnabled === window[NAMESPACE].liveReloadEnabled) { 139 | // No change in state 140 | return; 141 | } 142 | 143 | // Mark new state 144 | window[NAMESPACE].liveReloadEnabled = isEnabled; 145 | 146 | if (isEnabled) { 147 | if ('budo-livereload' in window) { 148 | console.log(`%c[canvas-sketch-cli]%c ✨ Hot Reload Enabled`, 'color: #8e8e8e;', 'color: initial;'); 149 | const client = window['budo-livereload']; 150 | client.listen(onClientData); 151 | } 152 | } 153 | } 154 | 155 | function onClientData (data) { 156 | const client = window['budo-livereload']; 157 | if (!client) return; 158 | 159 | if (data.event === 'eval') { 160 | if (!data.error) { 161 | client.clearError(); 162 | } 163 | try { 164 | eval(data.code); 165 | if (!data.error) console.log(`%c[canvas-sketch-cli]%c ✨ Hot Reloaded`, 'color: #8e8e8e;', 'color: initial;'); 166 | } catch (err) { 167 | console.error(`%c[canvas-sketch-cli]%c 🚨 Hot Reload error`, 'color: #8e8e8e;', 'color: initial;'); 168 | client.showError(err.toString()); 169 | 170 | // This will also load up the problematic script so that stack traces with 171 | // source maps is visible 172 | const scriptElement = document.createElement('script'); 173 | scriptElement.onload = () => { 174 | document.body.removeChild(scriptElement); 175 | }; 176 | scriptElement.src = data.src; 177 | document.body.appendChild(scriptElement); 178 | } 179 | } 180 | } -------------------------------------------------------------------------------- /src/logger.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | const { wrap } = require('cli-format'); 3 | const isError = require('is-error'); 4 | const defined = require('defined'); 5 | 6 | module.exports.createLogger = function (opts = {}) { 7 | const quiet = opts.quiet; 8 | 9 | let needsPadding = false; 10 | const width = process.stdout.columns || 80; 11 | const wordWrap = str => wrap(str, { paddingLeft: ' ', paddingRight: ' ', width }); 12 | const getPadded = (str, opt = {}) => { 13 | if (opt.leadingSpace !== false) str = `\n${str}`; 14 | return str; 15 | }; 16 | const getWrappedPadded = (str, opt = {}) => getPadded(opt.wordWrap !== false ? wordWrap(str) : str, opt); 17 | 18 | const bullet = chalk.bold(chalk.green('→ ')); 19 | 20 | const writeln = (msg = '') => { 21 | // Write all log output to stderr 22 | if (!quiet) console.error(msg); 23 | }; 24 | 25 | return { 26 | pad () { 27 | if (needsPadding) { 28 | writeln(); 29 | needsPadding = false; 30 | } 31 | }, 32 | writeLine (msg = '') { 33 | needsPadding = true; 34 | writeln(msg); 35 | }, 36 | log (msg = '', opt = {}) { 37 | needsPadding = true; 38 | if (msg) { 39 | msg = `${defined(opt.bullet, bullet)}${msg}`; 40 | if (opt.padding !== false) msg = getWrappedPadded(msg, opt); 41 | } 42 | writeln(msg || ''); 43 | }, 44 | error (header = '', body = '', opt = {}) { 45 | needsPadding = true; 46 | let wrapping = true; 47 | if (typeof header !== 'string' && isError(header) && header) { 48 | const { message, stack } = module.exports.getErrorDetails(header); 49 | header = message; 50 | body = stack; 51 | wrapping = false; 52 | } else if (typeof body !== 'string' && isError(body) && body) { 53 | body = module.exports.getErrorDetails(body).stack; 54 | wrapping = false; 55 | } 56 | 57 | header = chalk.red(`${opt.prefix || 'Error: '}${header}`); 58 | if (!wrapping) header = ` ${header}`; 59 | 60 | let msg; 61 | msg = [ header, body ].filter(Boolean).join('\n\n'); 62 | if (wrapping) { 63 | msg = getWrappedPadded(msg, opt); 64 | } else { 65 | msg = getPadded(msg, opt); 66 | } 67 | 68 | writeln(msg); 69 | } 70 | }; 71 | }; 72 | 73 | module.exports.getErrorDetails = function (err) { 74 | if (!err.stack) { 75 | return { 76 | message: err.message 77 | }; 78 | } 79 | const msg = err.stack; 80 | const lines = msg.split('\n'); 81 | let endIdx = lines.findIndex(line => line.trim().startsWith('at ')); 82 | if (endIdx === -1 || endIdx === 0) endIdx = 1; 83 | let message = lines.slice(0, endIdx).join('\n').replace(/^Error:/, '').trim(); 84 | const stack = lines.slice(endIdx).join('\n'); 85 | return { message, stack: err.hideStack ? '' : stack }; 86 | }; 87 | -------------------------------------------------------------------------------- /src/middleware.js: -------------------------------------------------------------------------------- 1 | const commit = require('./commit'); 2 | const { createStream } = require('./ffmpeg-sequence'); 3 | const path = require('path'); 4 | const Busboy = require('busboy'); 5 | const concat = require('concat-stream'); 6 | const fs = require('fs'); 7 | const mkdirp = require('mkdirp'); 8 | const bodyParser = require('body-parser'); 9 | 10 | module.exports = (opt = {}) => { 11 | const logger = opt.logger; 12 | const quiet = opt.quiet; 13 | const output = opt.output; 14 | const streamOpt = opt.stream || {}; 15 | const stream = streamOpt.format; 16 | 17 | // TODO: Buffering is not supported at the moment 18 | // Something to do with the stream not calling 'end' events 19 | // streamOpt.buffer; 20 | const bufferFrames = true; 21 | 22 | if (stream && (stream !== 'gif' && stream !== 'mp4')) { 23 | throw new Error('Currently the --stream flag must be either gif, mp4, or --no-stream (default)'); 24 | } 25 | 26 | let currentStream, currentStreamFilename; 27 | let isStreaming = Boolean(stream); 28 | 29 | const logError = err => { 30 | logger.error(err); 31 | logger.pad(); 32 | }; 33 | 34 | const sendError = (res, err) => { 35 | logError(err); 36 | res.end(JSON.stringify({ error: err.message })); 37 | }; 38 | 39 | return { 40 | // Ignore these in budo to avoid console spam, especially with animation export 41 | ignoreLog: [ 42 | '/canvas-sketch-cli/saveBlob', 43 | '/canvas-sketch-cli/commit', 44 | '/canvas-sketch-cli/stream-start', 45 | '/canvas-sketch-cli/stream-end' 46 | ], 47 | middleware: [ 48 | bodyParser.json(), 49 | (req, res, next) => { 50 | const post = /post/i.test(req.method); 51 | if (post && req.url === '/canvas-sketch-cli/saveBlob') { 52 | handleSaveBlob(req, res, next); 53 | } else if (post && req.url === '/canvas-sketch-cli/commit') { 54 | handleCommit(req, res, next); 55 | } else if (post && req.url === '/canvas-sketch-cli/stream-start') { 56 | handleStreamStart(req, res, next); 57 | } else if (post && req.url === '/canvas-sketch-cli/stream-end') { 58 | handleStreamEnd(req, res, next); 59 | } else { 60 | next(null); 61 | } 62 | } 63 | ] 64 | }; 65 | 66 | function respond (res, obj) { 67 | res.statusCode = 200; 68 | res.setHeader('Content-Type', 'application/json'); 69 | res.end(JSON.stringify(obj)); 70 | } 71 | 72 | function stopCurrentStream () { 73 | let p = Promise.resolve(); 74 | if (currentStream) { 75 | p = currentStream.end(); 76 | } 77 | return p.then(() => { 78 | currentStream = null; 79 | currentStreamFilename = null; 80 | }).catch(err => { 81 | console.error(err); 82 | }); 83 | } 84 | 85 | function handleStreamEnd (req, res, next) { 86 | const opt = req.body; 87 | const resOpt = { 88 | stream: isStreaming, 89 | filename: currentStreamFilename || opt.filename, 90 | outputName: path.basename(output), 91 | client: true 92 | }; 93 | 94 | if (!isStreaming) { 95 | return respond(res, resOpt); 96 | } 97 | 98 | stopCurrentStream().then(() => { 99 | respond(res, resOpt); 100 | }).catch(err => sendError(res, err)); 101 | } 102 | 103 | function handleStreamStart (req, res, next) { 104 | const opt = req.body; 105 | const resOpt = { 106 | stream: isStreaming, 107 | filename: opt.filename, 108 | outputName: path.basename(output), 109 | client: true 110 | }; 111 | 112 | if (!isStreaming) { 113 | return respond(res, resOpt); 114 | } 115 | 116 | if (!output) { 117 | return sendError(res, `Error trying to start stream, the --output flag has been disabled`); 118 | } 119 | 120 | stopCurrentStream().then(() => { 121 | const encoding = opt.encoding || 'image/png'; 122 | if (encoding !== 'image/png' && encoding !== 'image/jpeg') { 123 | return sendError(res, 'Could not start MP4 stream, you must use "image/png" or "image/jpeg" encoding'); 124 | } 125 | 126 | const format = stream; 127 | const fileName = `${path.basename(opt.filename)}.${format}`; 128 | const filePath = path.join(output, fileName); 129 | if (format === 'gif' && opt.fps > 50) { 130 | console.warn('WARN: Values above 50 FPS may produce choppy GIFs'); 131 | } 132 | currentStreamFilename = fileName; 133 | 134 | currentStream = createStream({ 135 | ...streamOpt, 136 | format, 137 | encoding, 138 | quiet: String(process.env.DEBUG_FFMPEG) !== '1', 139 | fps: opt.fps, 140 | output: filePath 141 | }); 142 | return currentStream.promise; 143 | }).then(() => { 144 | respond(res, resOpt); 145 | }); 146 | } 147 | 148 | function handleCommit (req, res, next) { 149 | commit(Object.assign({}, opt, { logger, quiet })).then(result => { 150 | respond(res, result); 151 | }).catch(err => { 152 | sendError(res, err); 153 | }); 154 | } 155 | 156 | function createBusboy (req, res) { 157 | try { 158 | return Busboy({ headers: req.headers }); 159 | } catch (err) { 160 | // Invalid headers in request 161 | res.statusCode = 500; 162 | res.setHeader('Content-Type', 'application/text'); 163 | res.end(err.message); 164 | return false; 165 | } 166 | } 167 | 168 | function handleSaveBlob (req, res, next) { 169 | if (!output) { 170 | return sendError(res, `Error trying to saveBlob, the --output flag has been disabled`); 171 | } 172 | 173 | let busboy = createBusboy(req, res); 174 | if (!busboy) return; 175 | 176 | let filename; 177 | let responded = false; 178 | let fileWritePromise = Promise.resolve(); 179 | busboy.once('file', (fieldName, file, info) => { 180 | const { mimeType } = info; 181 | fileWritePromise = new Promise((resolve, reject) => { 182 | mkdirp(output, err => { 183 | if (err) return reject(err); 184 | 185 | filename = path.basename(info.filename); 186 | const filePath = path.join(output, filename); 187 | const curFileName = filename; 188 | const usingStream = Boolean(isStreaming && currentStream); 189 | 190 | if (usingStream) { 191 | if (mimeType && mimeType !== currentStream.encoding) { 192 | reject(new Error('Error: Currently only single-image exports in image/png or image/jpeg format is supported with MP4 streaming')); 193 | } 194 | 195 | filename = currentStreamFilename; 196 | 197 | if (currentStream) { 198 | if (bufferFrames && typeof currentStream.writeBufferFrame === 'function') { 199 | file.pipe(concat(buf => { 200 | currentStream.writeBufferFrame(buf) 201 | .then(() => resolve()) 202 | .catch(err => reject(err)); 203 | })); 204 | } else { 205 | currentStream.writeFrame(file, curFileName) 206 | .then(() => resolve()) 207 | .catch(err => reject(err)); 208 | } 209 | } else { 210 | reject(new Error('WARN: MP4 stream stopped early')); 211 | } 212 | } else { 213 | const writer = fs.createWriteStream(filePath); 214 | const stream = file.pipe(writer); 215 | writer.once('error', reject); 216 | stream.once('error', reject); 217 | writer.once('finish', resolve); 218 | } 219 | }); 220 | }).catch(err => { 221 | responded = true; 222 | sendError(res, err); 223 | }); 224 | }); 225 | busboy.on('close', () => { 226 | fileWritePromise 227 | .then(() => { 228 | if (responded) return; 229 | const usingStream = Boolean(isStreaming && currentStream); 230 | responded = true; 231 | respond(res, { 232 | filename: filename, 233 | stream: usingStream, 234 | outputName: path.basename(output), 235 | client: true 236 | }); 237 | }).catch(err => { 238 | if (responded) return; 239 | responded = true; 240 | sendError(res, err); 241 | }); 242 | }); 243 | req.pipe(busboy); 244 | } 245 | }; 246 | -------------------------------------------------------------------------------- /src/plugins/plugin-env.js: -------------------------------------------------------------------------------- 1 | const envify = require('loose-envify'); 2 | const fromString = require('from2-string'); 3 | // const through = require('through2'); 4 | // const duplexer = require('duplexer2'); 5 | // const concatStream = require('concat-stream'); 6 | // const relativePath = require('cached-path-relative'); 7 | // const path = require('path'); 8 | 9 | // Utility -> true if path is a top-level node_modules (i.e. not in source) 10 | // const isNodeModule = (file, cwd) => { 11 | // const dir = path.dirname(file); 12 | // const relative = relativePath(cwd, dir); 13 | // return relative.startsWith(`node_modules${path.sep}`); 14 | // }; 15 | 16 | module.exports = (params = {}) => { 17 | const isProd = params.mode === 'production'; 18 | return bundler => { 19 | const global = isProd ? true : undefined; 20 | bundler.transform(envify, { 21 | global, 22 | NODE_ENV: isProd ? 'production' : 'development' 23 | }); 24 | 25 | const storageKey = isProd ? 'window.location.href' : JSON.stringify(params.entry); 26 | // Pass down a default storage key 27 | bundler.add(fromString(` 28 | global.CANVAS_SKETCH_DEFAULT_STORAGE_KEY = ${storageKey}; 29 | `), { 30 | file: 'canvas-sketch-cli/injected/storage-key.js' 31 | }); 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /src/plugins/plugin-glsl.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const through = require('through2'); 3 | const duplexer = require('duplexer2'); 4 | const concatStream = require('concat-stream'); 5 | const install = require('../install'); 6 | const glslify = require('glslify'); 7 | 8 | module.exports = (params = {}) => { 9 | const cwd = params.cwd || process.cwd(); 10 | return (file, bundlerOpt = {}) => { 11 | const output = through(); 12 | const ext = path.extname(file || '').toLowerCase(); 13 | 14 | // skip non-GLSL files 15 | if (!module.exports.extensions.includes(ext)) { 16 | return output; 17 | } 18 | 19 | const basedir = path.dirname(file); 20 | const stream = duplexer(concatStream(str => { 21 | str = str.toString(); 22 | // Compile with glslify 23 | try { 24 | str = glslify.compile(str, { 25 | basedir 26 | }); 27 | output.end(`module.exports = ${JSON.stringify(str)};`); 28 | } catch (err) { 29 | stream.emit('error', err); 30 | } 31 | }), output); 32 | return stream; 33 | }; 34 | }; 35 | 36 | module.exports.extensions = [ 37 | '.glsl', 38 | '.vert', 39 | '.frag', 40 | '.geom', 41 | '.vs', 42 | '.fs', 43 | '.gs', 44 | '.vsh', 45 | '.fsh', 46 | '.gsh', 47 | '.vshader', 48 | '.fshader', 49 | '.gshader' 50 | ]; 51 | -------------------------------------------------------------------------------- /src/plugins/plugin-resolve.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { isCanvasSketchPackage, needsUpdate } = require('../util'); 3 | const chalk = require('chalk'); 4 | 5 | module.exports = function createPlugin (settings = {}) { 6 | return function pluginResolve (bundler, opt = {}) { 7 | // Get this module's basedir 8 | const basedir = path.resolve(__dirname, '../'); 9 | const resolver = bundler._bresolve; 10 | 11 | // Clean up the browser resolve function a little bit 12 | bundler._bresolve = function (id, opts, cb) { 13 | // When running from within the "canvas-sketch" folder, let's also 14 | // re-direct any require to that folder. This way users can git clone 15 | // and test without having to write require('../') to point to the library. 16 | if (opts.package && opts.package.name && id === opts.package.name) { 17 | // Package is marked as a canvas-sketch repo (or fork...) 18 | if (isCanvasSketchPackage(opts.package)) { 19 | id = './'; 20 | opts = Object.assign({}, opts, { basedir: opts.package.__dirname }); 21 | } 22 | } 23 | 24 | // Resolve glslify always from here, since it may not be installed in the user project 25 | if (/^glslify([\\/].*)?$/.test(id)) { 26 | opts = Object.assign({}, opts, { basedir }); 27 | } 28 | 29 | return resolver.call(bundler, id, opts, (err, result, pkg) => { 30 | if (err) { 31 | cb(err); 32 | } else { 33 | // Small warning to handle removal of "module" field in recent versions 34 | if (pkg && pkg.name === 'canvas-sketch' && pkg.version && needsUpdate(pkg.version)) { 35 | if (settings.logger) { 36 | settings.logger.log(`${chalk.bold(chalk.yellow('WARN:'))} The version of ${chalk.bold('canvas-sketch')} is older than the CLI tool expects; you should update it to avoid conflicts and bundler errors.\n\nTo update:\n\n ${chalk.bold('npm install canvas-sketch@latest --save')}`); 37 | settings.logger.pad(); 38 | } 39 | } 40 | cb(null, result, pkg); 41 | } 42 | }); 43 | }; 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /src/plugins/transform-installer.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const through = require('through2'); 3 | const duplexer = require('duplexer2'); 4 | const concatStream = require('concat-stream'); 5 | // const relativePath = require('cached-path-relative'); 6 | const install = require('../install'); 7 | 8 | // Utility -> true if path is a top-level node_modules (i.e. not in source) 9 | // const isNodeModule = (file, cwd) => { 10 | // const dir = path.dirname(file); 11 | // const relative = relativePath(cwd, dir); 12 | // return relative.startsWith(`node_modules${path.sep}`); 13 | // }; 14 | 15 | module.exports = (params = {}) => { 16 | const cwd = params.cwd || process.cwd(); 17 | return (file, bundlerOpt = {}) => { 18 | const output = through(); 19 | if (/\.json$/i.test(file)) { 20 | return output; 21 | } 22 | 23 | return duplexer(concatStream(str => { 24 | str = str.toString(); 25 | install(file, { 26 | installer: params.installer, 27 | logger: params.logger, 28 | cwd, 29 | entrySrc: str, 30 | maxDepth: 1 31 | }) 32 | .then(() => { 33 | output.end(str); 34 | }).catch(() => { 35 | // Let errors bubble up from other transforms instead of this one 36 | output.end(str); 37 | // output.emit('error', err); 38 | // output.end(str); 39 | // const filepath = path.relative(cwd, file); 40 | // console.error(`Error processing ${filepath} for auto-module installation`); 41 | // console.error(err); 42 | // output.end(str); 43 | }); 44 | }), output); 45 | }; 46 | }; 47 | -------------------------------------------------------------------------------- /src/templates/alt-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | sketches 7 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/templates/default.js: -------------------------------------------------------------------------------- 1 | const canvasSketch = require('canvas-sketch'); 2 | 3 | const settings = { 4 | dimensions: [ 2048, 2048 ] 5 | }; 6 | 7 | const sketch = () => { 8 | return ({ context, width, height }) => { 9 | context.fillStyle = 'white'; 10 | context.fillRect(0, 0, width, height); 11 | }; 12 | }; 13 | 14 | canvasSketch(sketch, settings); 15 | -------------------------------------------------------------------------------- /src/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{title}} 7 | 33 | 34 | 35 | {{entry}} 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/templates/p5.js: -------------------------------------------------------------------------------- 1 | const canvasSketch = require('canvas-sketch'); 2 | const p5 = require('p5'); 3 | 4 | const preload = p5 => { 5 | // You can use p5.loadImage() here, etc... 6 | }; 7 | 8 | const settings = { 9 | // Pass the p5 instance, and preload function if necessary 10 | p5: { p5, preload }, 11 | // Turn on a render loop 12 | animate: true 13 | }; 14 | 15 | canvasSketch(() => { 16 | // Return a renderer, which is like p5.js 'draw' function 17 | return ({ p5, time, width, height }) => { 18 | // Draw with p5.js things 19 | p5.background(0); 20 | p5.fill(255); 21 | p5.noStroke(); 22 | 23 | const anim = p5.sin(time - p5.PI / 2) * 0.5 + 0.5; 24 | p5.rect(0, 0, width * anim, height); 25 | }; 26 | }, settings); 27 | -------------------------------------------------------------------------------- /src/templates/penplot.js: -------------------------------------------------------------------------------- 1 | const canvasSketch = require('canvas-sketch'); 2 | const { renderPaths, createPath, pathsToPolylines } = require('canvas-sketch-util/penplot'); 3 | const { clipPolylinesToBox } = require('canvas-sketch-util/geometry'); 4 | const Random = require('canvas-sketch-util/random'); 5 | 6 | // You can force a specific seed by replacing this with a string value 7 | const defaultSeed = ''; 8 | 9 | // Set a random seed so we can reproduce this print later 10 | Random.setSeed(defaultSeed || Random.getRandomSeed()); 11 | 12 | // Print to console so we can see which seed is being used and copy it if desired 13 | console.log('Random Seed:', Random.getSeed()); 14 | 15 | const settings = { 16 | suffix: Random.getSeed(), 17 | dimensions: 'A4', 18 | orientation: 'portrait', 19 | pixelsPerInch: 300, 20 | scaleToView: true, 21 | units: 'cm' 22 | }; 23 | 24 | const sketch = (props) => { 25 | const { width, height, units } = props; 26 | 27 | // Holds all our 'path' objects 28 | // which could be from createPath, or SVGPath string, or polylines 29 | const paths = []; 30 | 31 | // Draw random arcs 32 | const count = 450; 33 | for (let i = 0; i < count; i++) { 34 | // setup arc properties randomly 35 | const angle = Random.gaussian(0, Math.PI / 2); 36 | const arcLength = Math.abs(Random.gaussian(0, Math.PI / 2)); 37 | const r = ((i + 1) / count) * Math.min(width, height) / 1; 38 | 39 | // draw the arc 40 | const p = createPath(); 41 | p.arc(width / 2, height / 2, r, angle, angle + arcLength); 42 | paths.push(p); 43 | } 44 | 45 | // Convert the paths into polylines so we can apply line-clipping 46 | // When converting, pass the 'units' to get a nice default curve resolution 47 | let lines = pathsToPolylines(paths, { units }); 48 | 49 | // Clip to bounds, using a margin in working units 50 | const margin = 1; // in working 'units' based on settings 51 | const box = [ margin, margin, width - margin, height - margin ]; 52 | lines = clipPolylinesToBox(lines, box); 53 | 54 | // The 'penplot' util includes a utility to render 55 | // and export both PNG and SVG files 56 | return props => renderPaths(lines, { 57 | ...props, 58 | lineJoin: 'round', 59 | lineCap: 'round', 60 | // in working units; you might have a thicker pen 61 | lineWidth: 0.08, 62 | // Optimize SVG paths for pen plotter use 63 | optimize: true 64 | }); 65 | }; 66 | 67 | canvasSketch(sketch, settings); 68 | -------------------------------------------------------------------------------- /src/templates/regl.js: -------------------------------------------------------------------------------- 1 | const canvasSketch = require('canvas-sketch'); 2 | const createRegl = require('regl'); 3 | 4 | const settings = { 5 | // Make the loop animated 6 | animate: true, 7 | // Get a WebGL canvas rather than 2D 8 | context: 'webgl', 9 | // Turn on MSAA 10 | attributes: { antialias: true } 11 | }; 12 | 13 | const sketch = ({ gl }) => { 14 | // Setup REGL with our canvas context 15 | const regl = createRegl({ gl }); 16 | 17 | // Regl GL draw commands 18 | // ... 19 | 20 | // Return the renderer function 21 | return ({ time }) => { 22 | // Update regl sizes 23 | regl.poll(); 24 | 25 | // Clear back buffer 26 | regl.clear({ 27 | color: [ 0, 0, 0, 1 ] 28 | }); 29 | 30 | // Draw meshes to scene 31 | // ... 32 | }; 33 | }; 34 | 35 | canvasSketch(sketch, settings); 36 | -------------------------------------------------------------------------------- /src/templates/shader.js: -------------------------------------------------------------------------------- 1 | const canvasSketch = require('canvas-sketch'); 2 | const createShader = require('canvas-sketch-util/shader'); 3 | const glsl = require('glslify'); 4 | 5 | // Setup our sketch 6 | const settings = { 7 | context: 'webgl', 8 | animate: true 9 | }; 10 | 11 | // Your glsl code 12 | const frag = glsl(` 13 | precision highp float; 14 | 15 | uniform float time; 16 | varying vec2 vUv; 17 | 18 | void main () { 19 | vec3 color = 0.5 + 0.5 * cos(time + vUv.xyx + vec3(0.0, 2.0, 4.0)); 20 | gl_FragColor = vec4(color, 1.0); 21 | } 22 | `); 23 | 24 | // Your sketch, which simply returns the shader 25 | const sketch = ({ gl }) => { 26 | // Create the shader and return it 27 | return createShader({ 28 | // Pass along WebGL context 29 | gl, 30 | // Specify fragment and/or vertex shader strings 31 | frag, 32 | // Specify additional uniforms to pass down to the shaders 33 | uniforms: { 34 | // Expose props from canvas-sketch 35 | time: ({ time }) => time 36 | } 37 | }); 38 | }; 39 | 40 | canvasSketch(sketch, settings); 41 | -------------------------------------------------------------------------------- /src/templates/three.js: -------------------------------------------------------------------------------- 1 | // Ensure ThreeJS is in global scope for the 'examples/' 2 | global.THREE = require("three"); 3 | 4 | // Include any additional ThreeJS examples below 5 | require("three/examples/js/controls/OrbitControls"); 6 | 7 | const canvasSketch = require("canvas-sketch"); 8 | 9 | const settings = { 10 | // Make the loop animated 11 | animate: true, 12 | // Get a WebGL canvas rather than 2D 13 | context: "webgl" 14 | }; 15 | 16 | const sketch = ({ context }) => { 17 | // Create a renderer 18 | const renderer = new THREE.WebGLRenderer({ 19 | canvas: context.canvas 20 | }); 21 | 22 | // WebGL background color 23 | renderer.setClearColor("#000", 1); 24 | 25 | // Setup a camera 26 | const camera = new THREE.PerspectiveCamera(50, 1, 0.01, 100); 27 | camera.position.set(0, 0, -4); 28 | camera.lookAt(new THREE.Vector3()); 29 | 30 | // Setup camera controller 31 | const controls = new THREE.OrbitControls(camera, context.canvas); 32 | 33 | // Setup your scene 34 | const scene = new THREE.Scene(); 35 | 36 | // Setup a geometry 37 | const geometry = new THREE.SphereGeometry(1, 32, 16); 38 | 39 | // Setup a material 40 | const material = new THREE.MeshBasicMaterial({ 41 | color: "red", 42 | wireframe: true 43 | }); 44 | 45 | // Setup a mesh with geometry + material 46 | const mesh = new THREE.Mesh(geometry, material); 47 | scene.add(mesh); 48 | 49 | // draw each frame 50 | return { 51 | // Handle resize events here 52 | resize({ pixelRatio, viewportWidth, viewportHeight }) { 53 | renderer.setPixelRatio(pixelRatio); 54 | renderer.setSize(viewportWidth, viewportHeight, false); 55 | camera.aspect = viewportWidth / viewportHeight; 56 | camera.updateProjectionMatrix(); 57 | }, 58 | // Update & render your scene here 59 | render({ time }) { 60 | controls.update(); 61 | renderer.render(scene, camera); 62 | }, 63 | // Dispose of events & renderer for cleaner hot-reloading 64 | unload() { 65 | controls.dispose(); 66 | renderer.dispose(); 67 | } 68 | }; 69 | }; 70 | 71 | canvasSketch(sketch, settings); 72 | -------------------------------------------------------------------------------- /src/templates/two.js: -------------------------------------------------------------------------------- 1 | const canvasSketch = require('canvas-sketch'); 2 | const Two = require('two.js'); 3 | 4 | const settings = { 5 | dimensions: [ 1280, 1280 ], 6 | animate: true 7 | }; 8 | 9 | const sketch = ({ canvas, width, height }) => { 10 | // Create a new Two.js instance with our existing canvas 11 | const two = new Two({ domElement: canvas }); 12 | 13 | // Make a new rectangle that is placed in world center 14 | const size = width * 0.5; 15 | const background = new Two.Rectangle(0, 0, size, size); 16 | background.stroke = 'hsl(0, 0%, 25%)'; 17 | background.linewidth = width * 0.025; 18 | background.fill = 'tomato'; 19 | two.add(background); 20 | 21 | return { 22 | resize ({ pixelRatio, width, height }) { 23 | // Update width and height of Two.js scene based on 24 | // canvas-sketch auto changing viewport parameters 25 | two.width = width; 26 | two.height = height; 27 | two.ratio = pixelRatio; 28 | 29 | // This needs to be passed down to the renderer's width and height as well 30 | two.renderer.width = width; 31 | two.renderer.height = height; 32 | 33 | // Orient the scene to make 0, 0 the center of the canvas 34 | two.scene.translation.set(two.width / 2, two.height / 2); 35 | }, 36 | render ({ time }) { 37 | // Animate the rectangle 38 | background.rotation = time * 1.5; 39 | 40 | // Update two.js via the `render` method - *not* the `update` method. 41 | two.render(); 42 | } 43 | }; 44 | }; 45 | 46 | canvasSketch(sketch, settings); 47 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | const { promisify } = require('util'); 2 | const fs = require('fs'); 3 | const dateformat = require('dateformat'); 4 | const filenamify = require('filenamify'); 5 | const spawn = require('cross-spawn'); 6 | const semver = require('semver'); 7 | const path = require('path'); 8 | const chalk = require('chalk'); 9 | 10 | const readFile = promisify(fs.readFile); 11 | const minVersion = '0.0.10'; 12 | 13 | module.exports.generateFileName = (prefix = '', ext = '.js') => { 14 | const separator = prefix ? '-' : ''; 15 | const date = dateformat(Date.now(), 'yyyy.mm.dd-HH.MM.ss'); 16 | const file = `${prefix}${separator}${date}${ext}`; 17 | return filenamify(file); 18 | }; 19 | 20 | module.exports.spawnAsync = (cmd, args, opt) => { 21 | return new Promise((resolve, reject) => { 22 | const proc = spawn(cmd, args, opt); 23 | 24 | let stderr = ''; 25 | proc.stderr.on('data', (data) => { 26 | stderr += data.toString(); 27 | }); 28 | proc.on('exit', (code, msg) => { 29 | if (code === 0) resolve(); 30 | else reject(new Error(stderr)) 31 | }); 32 | }); 33 | }; 34 | 35 | module.exports.needsUpdate = function (version) { 36 | return semver.lt(version, minVersion); 37 | }; 38 | 39 | module.exports.isCanvasSketchPackage = function (pkg) { 40 | // new versions have this to mark the repo 41 | if (pkg.isCanvasSketch) return true; 42 | // old versions are based on name + GH repo 43 | if (pkg.name === 'canvas-sketch' && pkg.repository && pkg.repository.url === 'git://github.com/mattdesl/canvas-sketch.git') { 44 | return true; 45 | } 46 | return false; 47 | }; 48 | 49 | module.exports.readPackage = async (opt = {}, optional = false) => { 50 | const cwd = opt.cwd || process.cwd(); 51 | const pkgFile = path.resolve(cwd, 'package.json'); 52 | if (optional && !fs.existsSync(pkgFile)) return undefined; 53 | const data = await readFile(pkgFile, 'utf-8'); 54 | let pkg; 55 | try { 56 | pkg = JSON.parse(data); 57 | } catch (err) { 58 | throw new Error(`Error parsing JSON in "${chalk.bold('package.json')}": ${err.message}`); 59 | } 60 | return pkg; 61 | }; 62 | -------------------------------------------------------------------------------- /src/walk-local-deps.js: -------------------------------------------------------------------------------- 1 | const konan = require('konan'); 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | const { promisify } = require('util'); 5 | const defined = require('defined'); 6 | const babel = require('@babel/core'); 7 | 8 | // Gotta add these so babel doesn't bail out when it sees new syntax 9 | const pluginSyntaxRestSpread = require('@babel/plugin-syntax-object-rest-spread'); 10 | const pluginSyntaxGenerator = require('@babel/plugin-syntax-async-generators'); 11 | const pluginDynamicImport = require('@babel/plugin-syntax-dynamic-import'); 12 | const pluginCJS = require('@babel/plugin-transform-modules-commonjs'); 13 | 14 | // Usually we would support browser-resolve, 15 | // however in this case we just need to resolve 16 | // local deps which is no different in node/browser resolve algorithm 17 | const resolve = promisify(require('resolve')); 18 | const readFile = promisify(fs.readFile); 19 | const isLocal = /^[./\\/]/; 20 | const extensions = ['.js', '.jsx', '.es6', '.es']; 21 | 22 | module.exports = async (entry, opt = {}) => { 23 | const maxDepth = defined(opt.maxDepth, Infinity); 24 | const checked = []; 25 | const dependencies = []; 26 | 27 | const walk = async (file, src, curDepth = 0) => { 28 | // mark this file as checked 29 | checked.push(file); 30 | 31 | const ext = (path.extname(file) || '').toLowerCase(); 32 | if (!extensions.includes(ext)) { 33 | return; 34 | } 35 | 36 | if (typeof src === 'undefined') { 37 | src = await readFile(file, 'utf-8'); 38 | } 39 | 40 | const basedir = path.dirname(file); 41 | let deps; 42 | try { 43 | const babelResult = babel.transform(src, { 44 | ast: true, 45 | babelrc: true, 46 | plugins: [ 47 | pluginSyntaxRestSpread, 48 | pluginSyntaxGenerator, 49 | pluginDynamicImport, 50 | pluginCJS 51 | ], 52 | filename: file, 53 | sourceFileName: file, 54 | highlightCode: true 55 | }); 56 | const result = konan(babelResult.ast); 57 | deps = result.strings; 58 | } catch (err) { 59 | throw err; 60 | } 61 | 62 | // add each to final list of imports if it doesn't exist already 63 | deps.forEach(id => { 64 | const existing = dependencies.find(other => { 65 | return other.basedir === basedir && other.id === id; 66 | }); 67 | if (!existing) { 68 | dependencies.push({ basedir, id }); 69 | } 70 | }); 71 | 72 | // find any local dependencies 73 | const localDeps = deps.filter(req => isLocal.test(req)); 74 | 75 | // resolve them to real files 76 | let ids = await Promise.all(localDeps.map(dep => { 77 | return resolve(dep, { basedir }); 78 | })); 79 | 80 | // remove already checked files 81 | ids = ids.filter(id => !checked.includes(id)); 82 | 83 | // now let's walk each new dep 84 | curDepth++; 85 | if (curDepth <= maxDepth) { 86 | await Promise.all(ids.map(id => { 87 | return walk(id, undefined, curDepth); 88 | })); 89 | } 90 | }; 91 | 92 | await walk(entry, opt.entrySrc, 0); 93 | return dependencies.map(d => d.id); 94 | }; 95 | -------------------------------------------------------------------------------- /test/fixtures/auto-install-test-2.js: -------------------------------------------------------------------------------- 1 | // Ensure ThreeJS is in global scope for the 'examples/' 2 | global.THREE = require("three"); 3 | 4 | // Include any additional ThreeJS examples below 5 | require("three/examples/js/controls/OrbitControls"); 6 | 7 | const canvasSketch = require("canvas-sketch"); 8 | 9 | const settings = { 10 | // Make the loop animated 11 | animate: true, 12 | // Get a WebGL canvas rather than 2D 13 | context: "webgl" 14 | }; 15 | 16 | const sketch = ({ context }) => { 17 | // Create a renderer 18 | const renderer = new THREE.WebGLRenderer({ 19 | canvas: context.canvas 20 | }); 21 | 22 | // WebGL background color 23 | renderer.setClearColor("#000", 1); 24 | 25 | // Setup a camera 26 | const camera = new THREE.PerspectiveCamera(50, 1, 0.01, 100); 27 | camera.position.set(0, 0, -4); 28 | camera.lookAt(new THREE.Vector3()); 29 | 30 | // Setup camera controller 31 | const controls = new THREE.OrbitControls(camera, context.canvas); 32 | 33 | // Setup your scene 34 | const scene = new THREE.Scene(); 35 | 36 | // Setup a geometry 37 | const geometry = new THREE.SphereGeometry(1, 32, 16); 38 | 39 | // Setup a material 40 | const material = new THREE.MeshBasicMaterial({ 41 | color: "red", 42 | wireframe: true 43 | }); 44 | 45 | // Setup a mesh with geometry + material 46 | const mesh = new THREE.Mesh(geometry, material); 47 | scene.add(mesh); 48 | 49 | // draw each frame 50 | return { 51 | // Handle resize events here 52 | resize({ pixelRatio, viewportWidth, viewportHeight }) { 53 | renderer.setPixelRatio(pixelRatio); 54 | renderer.setSize(viewportWidth, viewportHeight, false); 55 | camera.aspect = viewportWidth / viewportHeight; 56 | camera.updateProjectionMatrix(); 57 | }, 58 | // Update & render your scene here 59 | render({ time }) { 60 | controls.update(); 61 | renderer.render(scene, camera); 62 | }, 63 | // Dispose of events & renderer for cleaner hot-reloading 64 | unload() { 65 | controls.dispose(); 66 | renderer.dispose(); 67 | } 68 | }; 69 | }; 70 | 71 | canvasSketch(sketch, settings); 72 | -------------------------------------------------------------------------------- /test/fixtures/auto-install-test.js: -------------------------------------------------------------------------------- 1 | import noop from "no-op"; 2 | console.log(typeof noop) 3 | -------------------------------------------------------------------------------- /test/fixtures/bar.js: -------------------------------------------------------------------------------- 1 | module.exports = () => {}; -------------------------------------------------------------------------------- /test/fixtures/deep/test.js: -------------------------------------------------------------------------------- 1 | const three = require('three'); 2 | console.log(three); 3 | require('../bar.js'); 4 | require('../foo.js'); 5 | -------------------------------------------------------------------------------- /test/fixtures/depth-0.js: -------------------------------------------------------------------------------- 1 | require('util'); 2 | require('./depth-1'); -------------------------------------------------------------------------------- /test/fixtures/depth-1.js: -------------------------------------------------------------------------------- 1 | require('http'); 2 | require('./depth-2'); 3 | -------------------------------------------------------------------------------- /test/fixtures/depth-2.js: -------------------------------------------------------------------------------- 1 | require('events'); 2 | -------------------------------------------------------------------------------- /test/fixtures/esm-test.js: -------------------------------------------------------------------------------- 1 | // console.log("FOO" ?? 2) 2 | // import canvasSketch from "canvas-sketch"; 3 | import { convert, sRGB, OKLCH } from "@texel/color/src/core.js"; 4 | 5 | // console.log(typeof canvasSketch === 'function'); 6 | // console.log(convert([1,0.5,0], sRGB, OKLCH)); 7 | 8 | // const vec3 = () => 'a'; 9 | 10 | // const convert = (input, fromSpace, toSpace, out = vec3()) => { 11 | // console.log("HI") 12 | // } -------------------------------------------------------------------------------- /test/fixtures/foo.js: -------------------------------------------------------------------------------- 1 | require('util'); 2 | require('foo-bar/blah/bar.js'); 3 | require('./deep/test.js'); 4 | require('./foo.js'); 5 | require('./second'); 6 | console.log('hi'); 7 | const a = async () => {}; 8 | -------------------------------------------------------------------------------- /test/fixtures/second.js: -------------------------------------------------------------------------------- 1 | module.exports = 1; 2 | -------------------------------------------------------------------------------- /test/fixtures/shader-import.js: -------------------------------------------------------------------------------- 1 | import shader from './shader.glsl'; 2 | console.log(shader); 3 | -------------------------------------------------------------------------------- /test/fixtures/shader-require.js: -------------------------------------------------------------------------------- 1 | console.log(require('./shader.glsl')); 2 | -------------------------------------------------------------------------------- /test/fixtures/shader.glsl: -------------------------------------------------------------------------------- 1 | void main () { 2 | gl_FragColor = vec4(1.0); 3 | } -------------------------------------------------------------------------------- /test/template-util.js: -------------------------------------------------------------------------------- 1 | require('three'); -------------------------------------------------------------------------------- /test/template.js: -------------------------------------------------------------------------------- 1 | require('./template-util'); -------------------------------------------------------------------------------- /test/test-glsl.js: -------------------------------------------------------------------------------- 1 | const test = require('tape'); 2 | const path = require('path'); 3 | const browserify = require('browserify'); 4 | const pluginGLSL = require('../src/plugins/plugin-glsl'); 5 | const { runInNewContext } = require('vm'); 6 | const esmify = require('esmify'); 7 | 8 | test('should require shader files', t => { 9 | t.plan(1); 10 | browserify(path.resolve(__dirname, 'fixtures/shader-require.js'), { 11 | transform: [ pluginGLSL() ] 12 | }).bundle((err, result) => { 13 | if (err) return t.fail(err); 14 | 15 | runInNewContext(result.toString(), { 16 | console: { 17 | log (msg) { 18 | t.equal(msg, '#define GLSLIFY 1\nvoid main () {\n gl_FragColor = vec4(1.0);\n}'); 19 | } 20 | } 21 | }); 22 | }); 23 | }); 24 | 25 | test('should import shader files', t => { 26 | t.plan(1); 27 | browserify(path.resolve(__dirname, 'fixtures/shader-import.js'), { 28 | transform: [ pluginGLSL() ], 29 | plugin: [ 30 | (bundler, opts) => { 31 | esmify(bundler, Object.assign({}, opts, { 32 | mainFields: [ 'browser', 'main' ], 33 | nodeModules: false 34 | })); 35 | } 36 | ], 37 | }).bundle((err, result) => { 38 | if (err) return t.fail(err); 39 | runInNewContext(result.toString(), { 40 | console: { 41 | log (msg) { 42 | t.equal(msg, '#define GLSLIFY 1\nvoid main () {\n gl_FragColor = vec4(1.0);\n}'); 43 | } 44 | } 45 | }); 46 | }); 47 | }); -------------------------------------------------------------------------------- /test/test-walk-deps.js: -------------------------------------------------------------------------------- 1 | const test = require('tape'); 2 | const walk = require('../src/walk-local-deps'); 3 | const path = require('path'); 4 | 5 | test('should walk local deps', async t => { 6 | t.plan(1); 7 | const dependencies = await walk(path.resolve(__dirname, 'fixtures/foo.js')); 8 | t.deepEqual(dependencies, [ 9 | 'util', 'foo-bar/blah/bar.js', 10 | './deep/test.js', './foo.js', './second', 11 | 'three', '../bar.js', '../foo.js' 12 | ]); 13 | }); 14 | 15 | test('should walk local deps with depth', async t => { 16 | t.plan(1); 17 | const dependencies = await walk(path.resolve(__dirname, 'fixtures/depth-0.js'), { 18 | maxDepth: 0 19 | }); 20 | t.deepEqual(dependencies, [ 21 | 'util', './depth-1' 22 | ]); 23 | }); 24 | 25 | test('should walk local deps with depth', async t => { 26 | t.plan(1); 27 | const dependencies = await walk(path.resolve(__dirname, 'fixtures/depth-0.js'), { 28 | maxDepth: 1 29 | }); 30 | t.deepEqual(dependencies, [ 31 | 'util', './depth-1', 'http', './depth-2' 32 | ]); 33 | }); 34 | 35 | test('should walk local deps with depth', async t => { 36 | t.plan(1); 37 | const dependencies = await walk(path.resolve(__dirname, 'fixtures/depth-0.js'), { 38 | maxDepth: 2 39 | }); 40 | t.deepEqual(dependencies, [ 41 | 'util', './depth-1', 'http', './depth-2', 'events' 42 | ]); 43 | }); 44 | 45 | test('should walk local deps with depth', async t => { 46 | t.plan(1); 47 | const dependencies = await walk(path.resolve(__dirname, 'fixtures/depth-0.js')); 48 | t.deepEqual(dependencies, [ 49 | 'util', './depth-1', 'http', './depth-2', 'events' 50 | ]); 51 | }); 52 | 53 | test('should walk local deps with entry source code', async t => { 54 | t.plan(1); 55 | const dependencies = await walk(path.resolve(__dirname, 'fixtures/depth-0.js'), { 56 | entrySrc: `require('foobar');` 57 | }); 58 | t.deepEqual(dependencies, [ 59 | 'foobar' 60 | ]); 61 | }); 62 | 63 | test('should ignore non JS files', async t => { 64 | t.plan(1); 65 | const dependencies = await walk(path.resolve(__dirname, 'fixtures/shader-require.js')); 66 | t.deepEqual(dependencies, [ './shader.glsl' ]); 67 | }); --------------------------------------------------------------------------------