mirror of
https://github.com/motion-canvas/motion-canvas.git
synced 2026-01-12 15:28:03 -05:00
feat: playback controls
This commit is contained in:
@@ -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>
|
||||
|
||||
123
src/Player.ts
123
src/Player.ts
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user