feat: playback controls

This commit is contained in:
aarthificial
2022-02-22 22:52:31 +01:00
parent a5416828bf
commit 94dab5dc1b
5 changed files with 156 additions and 25 deletions

View File

@@ -20,11 +20,58 @@
align-items: center;
justify-content: center;
background: black;
font-family: Roboto, sans-serif;
}
#controls {
padding: 8px;
border-radius: 8px;
display: grid;
grid-auto-flow: column;
grid-gap: 8px;
position: absolute;
top: 0;
left: 0;
color: white;
background: rgba(0, 0, 0, 0.3);
}
</style>
</head>
<body>
<div id="container"></div>
<form id="controls">
<div>
<input type="checkbox" id="controls-play" name="play" checked />
<label for="controls-play">Play</label>
</div>
<div>
<input type="checkbox" id="controls-loop" name="loop" checked />
<label for="controls-loop">Loop</label>
</div>
<input type="number" id="controls-from" name="from" value="0" />
<input type="button" id="controls-reset" name="refresh" value="Reset" />
<input type="button" id="controls-next" name="next" value="Next" />
<div>
<label for="controls-current">Current:</label>
<input
type="number"
readonly
id="controls-current"
name="current"
value="0"
/>
</div>
<div>
<label for="controls-speed">Speed:</label>
<select name="speed" id="controls-speed">
<option value="2">2x</option>
<option value="1.5">1.5x</option>
<option value="1" selected>1x</option>
<option value="0.5">0.5x</option>
<option value="0.25">0.25x</option>
</select>
</div>
</form>
<script src="index.js"></script>
</body>
</html>

View File

@@ -3,24 +3,122 @@ import {Project} from './Project';
const MINIMUM_ANIMATION_DURATION = 1000;
const MAX_AUDIO_DESYNC = 1 / 50;
class Controls {
private play: HTMLInputElement;
private loop: HTMLInputElement;
private from: HTMLInputElement;
private current: HTMLInputElement;
private speed: HTMLInputElement;
private stepRequested: boolean = false;
private resetRequested: boolean = false;
public get isPlaying(): boolean {
if (this.stepRequested) {
this.stepRequested = false;
return true;
}
return this.play.checked;
}
public get isLooping(): boolean {
return this.loop.checked;
}
public get startFrom(): number {
return parseInt(this.from.value);
}
public get shouldReset(): boolean {
if (this.resetRequested) {
this.resetRequested = false;
return true;
}
return false;
}
public get playbackSpeed(): number {
return parseFloat(this.speed.value);
}
public constructor(private form: HTMLFormElement) {
this.play = form.play;
this.loop = form.loop;
this.from = form.from;
this.current = form.current;
this.speed = form.speed;
form.next.addEventListener('click', () => {
this.stepRequested = true;
});
form.refresh.addEventListener('click', () => {
this.resetRequested = true;
});
}
public onFrame(frame: number) {
this.current.value = Math.floor(frame).toString();
}
}
export function Player(factory: () => Project, audioSrc?: string) {
const controls = new Controls(
<HTMLFormElement>document.getElementById('controls'),
);
const project = factory();
let startTime = performance.now();
let finished = false;
let audio: HTMLAudioElement;
if (audioSrc) {
audio = new Audio(audioSrc);
audio.play();
}
project.start();
const reset = () => {
startTime = performance.now();
project.start();
project.next();
project.draw();
controls.onFrame(project.frame);
finished = false;
if (audio) {
audio.currentTime = 0;
}
};
const run = () => {
if (audio?.currentTime < project.time) {
if (controls.shouldReset) {
reset();
}
if (!controls.isPlaying || audio?.currentTime < project.time) {
requestAnimationFrame(run);
return;
}
try {
let finished = project.next();
if (finished) {
if (controls.isLooping) {
// Prevent animation from restarting too quickly.
const animationDuration = performance.now() - startTime;
if (animationDuration < MINIMUM_ANIMATION_DURATION) {
setTimeout(run, MINIMUM_ANIMATION_DURATION - animationDuration);
return;
}
reset();
} else {
requestAnimationFrame(run);
return;
}
}
finished = project.next(controls.playbackSpeed);
// Start from certain frame
while (project.frame < controls.startFrom && !finished) {
finished = project.next();
}
// Synchronize animation with audio.
if (audio?.currentTime - MAX_AUDIO_DESYNC > project.time) {
@@ -29,26 +127,13 @@ export function Player(factory: () => Project, audioSrc?: string) {
}
}
if (finished) {
// Prevent animation from restarting too quickly.
const animationDuration = performance.now() - startTime;
if (animationDuration < MINIMUM_ANIMATION_DURATION) {
setTimeout(run, MINIMUM_ANIMATION_DURATION - animationDuration);
return;
}
startTime = performance.now();
project.start();
project.next();
if (audio) {
audio.currentTime = 0;
}
}
project.draw();
controls.onFrame(project.frame);
requestAnimationFrame(run);
} catch (e) {
console.error(e);
}
};
reset();
run();
}

View File

@@ -88,10 +88,9 @@ export class Project extends Stage {
});
}
public next(): boolean {
public next(speed: number = 1): boolean {
const result = this.runner.next();
this.draw();
this.frame++;
this.frame += speed;
return result.done;
}

View File

@@ -5,7 +5,8 @@ export function* waitUntil(
targetTime = 0,
after?: Generator,
): Generator {
while (this.frame < targetTime * this.framesPerSeconds) {
const frames = this.secondsToFrames(targetTime);
while (this.frame < frames) {
yield;
}

View File

@@ -4,12 +4,10 @@ import 'konva';
import path from 'path';
import fs from 'fs';
import webpack from 'webpack';
import os from 'os';
import {fileURLToPath} from 'url';
import canvas from 'canvas';
const {createCanvas, Image} = canvas;
const tmpDir = path.resolve(os.tmpdir(), 'motion-canvas');
const projectFile = path.resolve(process.cwd(), process.argv[2]);
const output = path.resolve(process.cwd(), process.argv[3] ?? 'output');
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -73,6 +71,7 @@ function build(entry) {
const project = setup.default.default(createCanvas, Image);
project.start();
while (!project.next()) {
project.draw();
const name = String(project.frame).padStart(6, '0');
const content = project.toDataURL().replace(/^data:image\/png;base64,/, '');
const size = (content.length * 2) / 1024;