From c9e2c6d0aa21ddb62c50b9fd367e3543eae027b3 Mon Sep 17 00:00:00 2001 From: Lucas Dino Nolte Date: Sat, 23 Apr 2022 12:18:11 +0200 Subject: [PATCH 1/8] feat: add framerate to sketch settings --- packages/core/src/mechanic.js | 8 ++++++-- packages/engine-canvas/index.js | 1 + packages/engine-p5/index.js | 1 + packages/engine-react/index.js | 2 ++ packages/engine-svg/index.js | 1 + 5 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/core/src/mechanic.js b/packages/core/src/mechanic.js index 1059cf42..172d6e4a 100644 --- a/packages/core/src/mechanic.js +++ b/packages/core/src/mechanic.js @@ -65,7 +65,11 @@ export class Mechanic { } } - this.settings = settings; + this.settings = { + frameRate: settings.frameRate || 60, + ...settings + }; + this.values = values; this.functionState = lastRun?.functionState ?? {}; } @@ -110,7 +114,7 @@ export class Mechanic { this.serializer = new XMLSerializer(); this.videoWriter = new WebMWriter({ quality: 0.95, - frameRate: 60 + frameRate: this.settings.frameRate }); } diff --git a/packages/engine-canvas/index.js b/packages/engine-canvas/index.js index 0a33a6e1..279d7b0f 100644 --- a/packages/engine-canvas/index.js +++ b/packages/engine-canvas/index.js @@ -40,6 +40,7 @@ export const run = (functionName, func, values, config) => { done: onDone, state: mechanic.functionState, setState: onSetState, + frameRate: mechanic.settings.frameRate, }, }); return mechanic; diff --git a/packages/engine-p5/index.js b/packages/engine-p5/index.js index 0762501e..8b7e3b4d 100644 --- a/packages/engine-p5/index.js +++ b/packages/engine-p5/index.js @@ -36,6 +36,7 @@ export const run = (functionName, func, values, config) => { done: onDone, state: mechanic.functionState, setState: onSetState, + frameRate: mechanic.settings.frameRate, }, sketch, }), diff --git a/packages/engine-react/index.js b/packages/engine-react/index.js index 233e7909..efef5984 100644 --- a/packages/engine-react/index.js +++ b/packages/engine-react/index.js @@ -24,6 +24,7 @@ export const run = (functionName, func, values, config) => { const onSetState = async (obj) => { mechanic.setState(obj); }; + render( { done: onDone, state: mechanic.functionState, setState: onSetState, + frameRate: mechanic.settings.frameRate, }} />, root diff --git a/packages/engine-svg/index.js b/packages/engine-svg/index.js index 4d69b4c7..cca52539 100644 --- a/packages/engine-svg/index.js +++ b/packages/engine-svg/index.js @@ -34,6 +34,7 @@ export const run = (functionName, func, values, config) => { done: onDone, state: mechanic.functionState, setState: onSetState, + frameRate: mechanic.settings.frameRate, }, }); return mechanic; From 98b43dd3b508add72d99867bcb7147a20a4e7be0 Mon Sep 17 00:00:00 2001 From: Lucas Dino Nolte Date: Sat, 23 Apr 2022 12:38:46 +0200 Subject: [PATCH 2/8] feat: export fps throttled animation loop from core --- packages/core/src/index.js | 1 + packages/core/src/mechanic-utils.js | 54 ++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 2174f62d..f06021f6 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -1 +1,2 @@ export { Mechanic } from "./mechanic.js"; +export { drawLoop } from "./mechanic-utils.js"; diff --git a/packages/core/src/mechanic-utils.js b/packages/core/src/mechanic-utils.js index 9524b155..63723f60 100644 --- a/packages/core/src/mechanic-utils.js +++ b/packages/core/src/mechanic-utils.js @@ -159,6 +159,57 @@ const getTimeStamp = () => { return `${year}-${month}-${day}-${hour}-${minute}`; }; +/** + * A throttled version of requestAnimationFrame to support preview + * at a certain framerate. + * + * Greatly inspired by how p5 handles its throttled drawloop. + * + * @param drawingFunction + * @param frameRate + */ +const drawLoop = (drawingFunction, frameRate = 60) => { + const fpsInterval = 1000 / frameRate; + const epsilon = 5; + + let lastFrameTime = 0; + let frameCount = 0; + let raf; + let isPlaying = true; + + const stop = () => { + isPlaying = false; + window.cancelAnimationFrame(raf); + }; + + const draw = () => { + const now = window.performance.now(); + const timeSinceLast = now - lastFrameTime; + + // The Epsilon is taken from p5's solution. Also copying their + // comment on why they do it here. + // + // From p5 source: + // only draw if we really need to; don't overextend the browser. + // draw if we're within 5ms of when our next frame should paint + // (this will prevent us from giving up opportunities to draw + // again when it's really about time for us to do so). fixes an + // issue where the frameRate is too low if our refresh loop isn't + // in sync with the browser. note that we have to draw once even + // if looping is off, so we bypass the time delay if that + // is the case. + if (isPlaying && timeSinceLast >= fpsInterval - epsilon) { + drawingFunction.call(null, { frameCount, stop }); + frameCount++; + lastFrameTime = now; + } + + if (isPlaying) { + raf = window.requestAnimationFrame(draw); + } + }; +}; + export { isSVG, isCanvas, @@ -172,5 +223,6 @@ export { htmlToCanvas, extractSvgSize, dataUrlToCanvas, - getTimeStamp + getTimeStamp, + drawLoop }; From 40fef0c081d6b0d0e05d09e26edd3e072e22e2ad Mon Sep 17 00:00:00 2001 From: Lucas Dino Nolte Date: Sat, 23 Apr 2022 12:44:26 +0200 Subject: [PATCH 3/8] feat: export throttled draw loop from react engine --- packages/engine-react/index.js | 50 +++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/engine-react/index.js b/packages/engine-react/index.js index efef5984..c69c7db7 100644 --- a/packages/engine-react/index.js +++ b/packages/engine-react/index.js @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useRef, useState, useEffect } from "react"; import { render, unmountComponentAtNode } from "react-dom"; import { Mechanic } from "@mechanic-design/core"; @@ -40,3 +40,51 @@ export const run = (functionName, func, values, config) => { ); return mechanic; }; + +/** + * Reactive version of the throttled drawloop. + * + * @param ref to a boolean + * @param target framerate + */ +export const useDrawLoop = (isPlaying, fps = 60) => { + const fpsInterval = 1000 / fps; + + const raf = useRef(); + const [frameCount, setFrameCount] = useState(0); + + // FPS Throttling + // @see https://stackoverflow.com/questions/19764018/controlling-fps-with-requestanimationframe + useEffect(() => { + let now; + let then = Date.now(); + let elapsed; + + cancelAnimationFrame(raf.current); + + if (!isPlaying) { + return; + } + + const draw = () => { + raf.current = requestAnimationFrame(draw); + + now = Date.now(); + elapsed = now - then; + + if (elapsed > fpsInterval) { + then = now - (elapsed % fpsInterval); + + setFrameCount((cur) => cur + 1); + } + }; + + draw(); + + return () => { + cancelAnimationFrame(raf.current); + }; + }, [isPlaying]); + + return frameCount; +}; From fedf51f313c73c3a6979120925836fb7d66f007f Mon Sep 17 00:00:00 2001 From: Lucas Dino Nolte Date: Mon, 25 Apr 2022 09:51:13 +0200 Subject: [PATCH 4/8] feat: add epsilon to react hook --- packages/engine-react/index.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/engine-react/index.js b/packages/engine-react/index.js index c69c7db7..4f3cea26 100644 --- a/packages/engine-react/index.js +++ b/packages/engine-react/index.js @@ -49,6 +49,7 @@ export const run = (functionName, func, values, config) => { */ export const useDrawLoop = (isPlaying, fps = 60) => { const fpsInterval = 1000 / fps; + const epsilon = 5; const raf = useRef(); const [frameCount, setFrameCount] = useState(0); @@ -72,7 +73,9 @@ export const useDrawLoop = (isPlaying, fps = 60) => { now = Date.now(); elapsed = now - then; - if (elapsed > fpsInterval) { + // Same epsilon logic as in core/mechanic-utils. + // See there for an explanation. + if (elapsed >= fpsInterval - epsilon) { then = now - (elapsed % fpsInterval); setFrameCount((cur) => cur + 1); From a3e149b014fc341b959f35a53a05e834c2e69fa9 Mon Sep 17 00:00:00 2001 From: Lucas Dino Nolte Date: Wed, 27 Apr 2022 21:53:18 +0200 Subject: [PATCH 5/8] fix: add initial call of draw function to drawLoop helper --- packages/core/src/mechanic-utils.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/mechanic-utils.js b/packages/core/src/mechanic-utils.js index 63723f60..e62a665d 100644 --- a/packages/core/src/mechanic-utils.js +++ b/packages/core/src/mechanic-utils.js @@ -208,6 +208,8 @@ const drawLoop = (drawingFunction, frameRate = 60) => { raf = window.requestAnimationFrame(draw); } }; + + draw(); }; export { From 0b49f6f03560f0cda21da77341404ba6221b77e5 Mon Sep 17 00:00:00 2001 From: Lucas Dino Nolte Date: Sat, 7 May 2022 14:55:47 +0200 Subject: [PATCH 6/8] feat: add videoQuality to settings --- packages/core/src/mechanic.js | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/core/src/mechanic.js b/packages/core/src/mechanic.js index 172d6e4a..82810f7e 100644 --- a/packages/core/src/mechanic.js +++ b/packages/core/src/mechanic.js @@ -67,11 +67,34 @@ export class Mechanic { this.settings = { frameRate: settings.frameRate || 60, + videoQuality: settings.videoQuality || 0.95, ...settings }; this.values = values; this.functionState = lastRun?.functionState ?? {}; + + // Validate the settings + this.validateSettings(); + } + + /** + * Helper function to validate settings + * Throws an exception if invalid settings + * are applied. + */ + validateSettings() { + // Validates that quality is within 0–0.99 + // + // 1 isn't a working value for the webm-writer we use, as it doesn't support + // losless export. The webm-writer clamps the quality value, so passing 1 to + // it would technically work. But this way is more transparent towards the + // users. We currently do not support losless video export. + if (this.settings.videoQuality < 0 || this.settings.videoQuality > 0.99) { + throw new MechanicError( + `The given videoQuality setting (${this.settings.videoQuality}) does not match the expected range (0–0.99)` + ); + } } /** @@ -113,7 +136,7 @@ export class Mechanic { this.exportInit = true; this.serializer = new XMLSerializer(); this.videoWriter = new WebMWriter({ - quality: 0.95, + quality: this.settings.videoQuality, frameRate: this.settings.frameRate }); } From e4f71b55ef3e193b2df7568b86efe3ac7f7fbf90 Mon Sep 17 00:00:00 2001 From: Lucas Dino Nolte Date: Sat, 7 May 2022 14:59:06 +0200 Subject: [PATCH 7/8] chore: update changelog --- packages/core/CHANGELOG.md | 5 +++++ packages/engine-react/CHANGELOG.md | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index a3fe9c55..b8fce6c1 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Add frameRate and videoQuality options to settings +- Add drawLoop utility function that allows previewing an animation at a specified frameRate + ## 2.0.0-beta - 2022-03-24 ### Added diff --git a/packages/engine-react/CHANGELOG.md b/packages/engine-react/CHANGELOG.md index 28b917d2..0955b95b 100644 --- a/packages/engine-react/CHANGELOG.md +++ b/packages/engine-react/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Exports `useDrawLoop` hook to allow previewing an animation at a specified frameRate + ## 2.0.0-beta - 2022-03-24 ### Added From 1b5489279fce39f93a3b7275783bfaa868cf9b45 Mon Sep 17 00:00:00 2001 From: Lucas Dino Nolte Date: Sat, 7 May 2022 17:07:10 +0200 Subject: [PATCH 8/8] chore: add function-templates using framebased animation --- packages/create-mechanic/CHANGELOG.md | 3 + .../canvas-video-framebased/dependencies.json | 5 + .../canvas-video-framebased/index.js | 105 +++++++++++++++++ .../function-templates/index.js | 15 +++ .../function-templates/p5-video/index.js | 2 + .../react-video-framebased/dependencies.json | 5 + .../react-video-framebased/index.js | 107 ++++++++++++++++++ .../svg-video-framebased/dependencies.json | 5 + .../svg-video-framebased/index.js | 101 +++++++++++++++++ 9 files changed, 348 insertions(+) create mode 100644 packages/create-mechanic/function-templates/canvas-video-framebased/dependencies.json create mode 100644 packages/create-mechanic/function-templates/canvas-video-framebased/index.js create mode 100644 packages/create-mechanic/function-templates/react-video-framebased/dependencies.json create mode 100644 packages/create-mechanic/function-templates/react-video-framebased/index.js create mode 100644 packages/create-mechanic/function-templates/svg-video-framebased/dependencies.json create mode 100644 packages/create-mechanic/function-templates/svg-video-framebased/index.js diff --git a/packages/create-mechanic/CHANGELOG.md b/packages/create-mechanic/CHANGELOG.md index 6978ee17..81626935 100644 --- a/packages/create-mechanic/CHANGELOG.md +++ b/packages/create-mechanic/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added +- Added function templates for framebased animations + ## 1.2.0 - 2022-02-14 ### Fixed diff --git a/packages/create-mechanic/function-templates/canvas-video-framebased/dependencies.json b/packages/create-mechanic/function-templates/canvas-video-framebased/dependencies.json new file mode 100644 index 00000000..2715603f --- /dev/null +++ b/packages/create-mechanic/function-templates/canvas-video-framebased/dependencies.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@mechanic-design/engine-canvas": "^2.0.0-beta.4" + } +} diff --git a/packages/create-mechanic/function-templates/canvas-video-framebased/index.js b/packages/create-mechanic/function-templates/canvas-video-framebased/index.js new file mode 100644 index 00000000..479c6119 --- /dev/null +++ b/packages/create-mechanic/function-templates/canvas-video-framebased/index.js @@ -0,0 +1,105 @@ +import { drawLoop } from "@mechanic-design/core"; + +export const handler = async ({ inputs, mechanic }) => { + const { width, height, text, color1, color2, radiusPercentage, turns } = + inputs; + + const center = [width / 2, height / 2]; + const radius = ((height / 2) * radiusPercentage) / 100; + let angle = 0; + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext("2d"); + + drawLoop(({ frameCount, stop }) => { + ctx.fillStyle = "#F4F4F4"; + ctx.fillRect(0, 0, width, height); + + ctx.fillStyle = color1; + ctx.beginPath(); + ctx.arc( + center[0], + center[1], + radius, + Math.PI + angle, + 2 * Math.PI + angle, + false + ); + ctx.fill(); + + ctx.fillStyle = color2; + ctx.beginPath(); + ctx.arc(center[0], center[1], radius, 0 + angle, Math.PI + angle, false); + ctx.fill(); + + ctx.fillStyle = "#000000"; + ctx.font = `${height / 10}px sans-serif`; + ctx.textAlign = "center"; + ctx.strokeText(text, width / 2, height - height / 20); + ctx.fillText(text, width / 2, height - height / 20); + + if (angle < turns * 2 * Math.PI) { + mechanic.frame(canvas); + angle = frameCount; + } else { + stop(); + mechanic.done(canvas); + } + }, mechanic.frameRate); +}; + +export const inputs = { + width: { + type: "number", + default: 400, + }, + height: { + type: "number", + default: 300, + }, + text: { + type: "text", + default: "mechanic", + }, + color1: { + type: "color", + model: "hex", + default: "#E94225", + }, + color2: { + type: "color", + model: "hex", + default: "#002EBB", + }, + radiusPercentage: { + type: "number", + default: 40, + min: 0, + max: 100, + slider: true, + }, + turns: { + type: "number", + default: 3, + }, +}; + +export const presets = { + medium: { + width: 800, + height: 600, + }, + large: { + width: 1600, + height: 1200, + }, +}; + +export const settings = { + engine: require("@mechanic-design/engine-canvas"), + animated: true, + frameRate: 60, +}; diff --git a/packages/create-mechanic/function-templates/index.js b/packages/create-mechanic/function-templates/index.js index 85193753..1e3b94d9 100644 --- a/packages/create-mechanic/function-templates/index.js +++ b/packages/create-mechanic/function-templates/index.js @@ -9,6 +9,11 @@ const options = [ type: "Canvas", dir: "canvas-video", }, + { + name: "Vanilla JS Video (Framebased)", + type: "Canvas", + dir: "canvas-video-framebased", + }, { name: "Vanilla JS Image", type: "SVG", @@ -19,6 +24,11 @@ const options = [ type: "SVG", dir: "svg-video", }, + { + name: "Vanilla JS Video (Framebased)", + type: "Canvas", + dir: "svg-video-framebased", + }, { name: "p5.js Image", type: "Canvas", @@ -39,6 +49,11 @@ const options = [ type: "SVG", dir: "react-video", }, + { + name: "React Video (Framebased)", + type: "SVG", + dir: "react-video-framebased", + }, ]; module.exports = options.reduce((acc, cur) => { diff --git a/packages/create-mechanic/function-templates/p5-video/index.js b/packages/create-mechanic/function-templates/p5-video/index.js index 76ac279c..7f2c5c7e 100644 --- a/packages/create-mechanic/function-templates/p5-video/index.js +++ b/packages/create-mechanic/function-templates/p5-video/index.js @@ -8,6 +8,7 @@ export const handler = ({ inputs, mechanic, sketch }) => { sketch.setup = () => { sketch.createCanvas(width, height); + sketch.frameRate(mechanic.frameRate); }; sketch.draw = () => { @@ -89,4 +90,5 @@ export const presets = { export const settings = { engine: require("@mechanic-design/engine-p5"), animated: true, + frameRate: 60, }; diff --git a/packages/create-mechanic/function-templates/react-video-framebased/dependencies.json b/packages/create-mechanic/function-templates/react-video-framebased/dependencies.json new file mode 100644 index 00000000..988ca32f --- /dev/null +++ b/packages/create-mechanic/function-templates/react-video-framebased/dependencies.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@mechanic-design/engine-react": "^2.0.0-beta.4" + } +} diff --git a/packages/create-mechanic/function-templates/react-video-framebased/index.js b/packages/create-mechanic/function-templates/react-video-framebased/index.js new file mode 100644 index 00000000..ea07ed58 --- /dev/null +++ b/packages/create-mechanic/function-templates/react-video-framebased/index.js @@ -0,0 +1,107 @@ +import React, { useEffect, useRef } from "react"; +import { useDrawLoop } from "@mechanic-design/engine-react"; + +export const handler = ({ inputs, mechanic }) => { + const { width, height, text, color1, color2, radiusPercentage, turns } = + inputs; + + const center = [width / 2, height / 2]; + const radius = ((height / 2) * radiusPercentage) / 100; + const angle = useRef(0); + + const isPlaying = useRef(true); + const frameCount = useDrawLoop(isPlaying.current, mechanic.frameRate); + + useEffect(() => { + if (angle.current < turns * 360) { + mechanic.frame(); + angle.current = frameCount; + } else if (isPlaying.current) { + isPlaying.current = false; + mechanic.done(); + } + }, [frameCount]); + + return ( + + + + + + + + + {text} + + + + ); +}; + +export const inputs = { + width: { + type: "number", + default: 400, + }, + height: { + type: "number", + default: 300, + }, + text: { + type: "text", + default: "mechanic", + }, + color1: { + type: "color", + model: "hex", + default: "#E94225", + }, + color2: { + type: "color", + model: "hex", + default: "#002EBB", + }, + radiusPercentage: { + type: "number", + default: 40, + min: 0, + max: 100, + slider: true, + }, + turns: { + type: "number", + default: 3, + }, +}; + +export const presets = { + medium: { + width: 800, + height: 600, + }, + large: { + width: 1600, + height: 1200, + }, +}; + +export const settings = { + engine: require("@mechanic-design/engine-react"), + animated: true, + frameRate: 60, +}; diff --git a/packages/create-mechanic/function-templates/svg-video-framebased/dependencies.json b/packages/create-mechanic/function-templates/svg-video-framebased/dependencies.json new file mode 100644 index 00000000..ee1fd618 --- /dev/null +++ b/packages/create-mechanic/function-templates/svg-video-framebased/dependencies.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@mechanic-design/engine-svg": "^2.0.0-beta.4" + } +} diff --git a/packages/create-mechanic/function-templates/svg-video-framebased/index.js b/packages/create-mechanic/function-templates/svg-video-framebased/index.js new file mode 100644 index 00000000..50bda286 --- /dev/null +++ b/packages/create-mechanic/function-templates/svg-video-framebased/index.js @@ -0,0 +1,101 @@ +import { drawLoop } from "@mechanic-design/core"; + +export const handler = ({ inputs, mechanic }) => { + const { width, height, text, color1, color2, radiusPercentage, turns } = + inputs; + + const center = [width / 2, height / 2]; + const radius = ((height / 2) * radiusPercentage) / 100; + + let angle = 0; + drawLoop(({ frameCount, stop }) => { + const svg = ` + + + + + + + + ${text} + + + `; + + if (angle < turns * 360) { + mechanic.frame(svg); + angle = frameCount; + } else { + stop(); + mechanic.done(svg); + } + }, mechanic.frameRate); +}; + +export const inputs = { + width: { + type: "number", + default: 400, + }, + height: { + type: "number", + default: 300, + }, + text: { + type: "text", + default: "mechanic", + }, + color1: { + type: "color", + model: "hex", + default: "#E94225", + }, + color2: { + type: "color", + model: "hex", + default: "#002EBB", + }, + radiusPercentage: { + type: "number", + default: 40, + min: 0, + max: 100, + slider: true, + }, + turns: { + type: "number", + default: 3, + }, +}; + +export const presets = { + medium: { + width: 800, + height: 600, + }, + large: { + width: 1600, + height: 1200, + }, +}; + +export const settings = { + engine: require("@mechanic-design/engine-svg"), + animated: true, + frameRate: 60, +};