mirror of
https://github.com/motion-canvas/motion-canvas.git
synced 2026-01-11 23:07:57 -05:00
feat: remove ui elements
This commit is contained in:
3287
package-lock.json
generated
3287
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -36,9 +36,14 @@
|
||||
"tsconfig.project.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/node-sass": "^4.11.2",
|
||||
"@types/three": "^0.138.0",
|
||||
"css-loader": "^6.7.1",
|
||||
"node-loader": "^2.0.0",
|
||||
"node-sass": "^7.0.1",
|
||||
"prettier": "^2.5.1",
|
||||
"sass-loader": "^12.6.0",
|
||||
"style-loader": "^3.3.1",
|
||||
"webpack-cli": "^4.9.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,81 +8,13 @@
|
||||
/>
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
<link rel="icon" href="data:;base64,iVBORw0KGgo=" />
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<title>Motion Canvas</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container"></div>
|
||||
<details id="threads">
|
||||
<summary>THREADS</summary>
|
||||
</details>
|
||||
<div id="ui">
|
||||
<div id="timeline">
|
||||
<div class="timeline-text js-current-time">0</div>
|
||||
<div class="track">
|
||||
<div class="fill fill-seek"></div>
|
||||
<div class="fill fill-time"></div>
|
||||
<div class="fill fill-start"></div>
|
||||
<div class="marker"></div>
|
||||
</div>
|
||||
<div class="timeline-text js-duration">10,000</div>
|
||||
</div>
|
||||
<form id="controls">
|
||||
<div class="icon-checkbox">
|
||||
<input type="checkbox" id="controls-audio" name="audio" checked />
|
||||
<label for="controls-audio"></label>
|
||||
</div>
|
||||
<input
|
||||
class="icon-button"
|
||||
type="button"
|
||||
id="controls-reset"
|
||||
name="refresh"
|
||||
/>
|
||||
<div class="icon-checkbox">
|
||||
<input type="checkbox" id="controls-play" name="play" checked />
|
||||
<label for="controls-play"></label>
|
||||
</div>
|
||||
<input
|
||||
class="icon-button"
|
||||
type="button"
|
||||
id="controls-next"
|
||||
name="next"
|
||||
/>
|
||||
<div class="icon-checkbox">
|
||||
<input type="checkbox" id="controls-loop" name="loop" checked />
|
||||
<label for="controls-loop"></label>
|
||||
</div>
|
||||
<div hidden>
|
||||
<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>
|
||||
<div>
|
||||
<label for="controls-fps">FPS:</label>
|
||||
<input
|
||||
type="number"
|
||||
readonly
|
||||
id="controls-fps"
|
||||
name="fps"
|
||||
value="0"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="button"
|
||||
id="controls-render"
|
||||
name="render"
|
||||
value="Render"
|
||||
/>
|
||||
<div id="loading">Loading...</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div hidden id="konva"></div>
|
||||
<main id="app"></main>
|
||||
<script src="runtime.js"></script>
|
||||
<script src="index.js"></script>
|
||||
<script src="ui.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,8 +5,10 @@ import {Rect} from 'konva/lib/shapes/Rect';
|
||||
import {Layer} from 'konva/lib/Layer';
|
||||
import {Vector2d} from 'konva/lib/types';
|
||||
import {Konva} from 'konva/lib/Global';
|
||||
import {ThreadsCallback} from './threading';
|
||||
import {Thread, ThreadsCallback} from './threading';
|
||||
import {Scene, SceneRunner} from './Scene';
|
||||
import {SimpleEventDispatcher} from 'strongly-typed-events';
|
||||
import {PlayerState} from './player/Player';
|
||||
|
||||
Konva.autoDrawEnabled = false;
|
||||
|
||||
@@ -19,6 +21,10 @@ const Sizes: Record<ProjectSize, [number, number]> = {
|
||||
};
|
||||
|
||||
export class Project extends Stage {
|
||||
public get ScenesChanged() {
|
||||
return this.scenesChanged.asEvent();
|
||||
}
|
||||
|
||||
public readonly background: Rect;
|
||||
public readonly foreground: Layer;
|
||||
public readonly center: Vector2d;
|
||||
@@ -30,9 +36,19 @@ export class Project extends Stage {
|
||||
return this.framesToSeconds(this.frame);
|
||||
}
|
||||
|
||||
public get scenes(): Scene[] {
|
||||
return Object.values(this.sceneLookup);
|
||||
}
|
||||
|
||||
public get thread(): Thread {
|
||||
return this.currentThread;
|
||||
}
|
||||
|
||||
private readonly scenesChanged = new SimpleEventDispatcher<Scene[]>();
|
||||
private readonly sceneLookup: Record<string, Scene> = {};
|
||||
private previousScene: Scene = null;
|
||||
private currentScene: Scene = null;
|
||||
private currentThread: Thread = null;
|
||||
|
||||
public constructor(
|
||||
scenes: SceneRunner[],
|
||||
@@ -76,9 +92,10 @@ export class Project extends Stage {
|
||||
}
|
||||
const handle = new Scene(this, scene);
|
||||
this.sceneLookup[scene.name] = handle;
|
||||
handle.threadsCallback = (...args) => {
|
||||
handle.threadsCallback = thread => {
|
||||
if (this.currentScene === handle) {
|
||||
this.threadsCallback?.(...args);
|
||||
this.currentThread = thread;
|
||||
this.threadsCallback?.(thread);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -95,6 +112,7 @@ export class Project extends Stage {
|
||||
for (const runner of runners) {
|
||||
this.sceneLookup[runner.name]?.reload(runner);
|
||||
}
|
||||
this.scenesChanged.dispatch(this.scenes);
|
||||
}
|
||||
|
||||
public async next(speed: number = 1): Promise<boolean> {
|
||||
@@ -103,10 +121,13 @@ export class Project extends Stage {
|
||||
if (!this.currentScene || this.currentScene.isAfterTransitionIn()) {
|
||||
this.previousScene.remove();
|
||||
this.previousScene.lastFrame = this.frame;
|
||||
this.scenesChanged.dispatch(this.scenes);
|
||||
this.previousScene = null;
|
||||
}
|
||||
}
|
||||
|
||||
this.frame += speed;
|
||||
|
||||
if (this.currentScene) {
|
||||
await this.currentScene.next();
|
||||
if (this.currentScene.canTransitionOut()) {
|
||||
@@ -115,17 +136,35 @@ export class Project extends Stage {
|
||||
if (this.currentScene) {
|
||||
await this.currentScene.reset(this.previousScene);
|
||||
this.currentScene.firstFrame = this.frame;
|
||||
this.scenesChanged.dispatch(this.scenes);
|
||||
this.add(this.currentScene);
|
||||
this.foreground.moveToTop();
|
||||
} else {
|
||||
this.previousScene.lastFrame = this.frame;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.frame += speed;
|
||||
|
||||
return !this.currentScene || this.currentScene.isFinished();
|
||||
}
|
||||
|
||||
public async recalculate() {
|
||||
this.previousScene?.remove();
|
||||
this.previousScene = null;
|
||||
this.currentScene?.remove();
|
||||
this.currentScene = this.findBestScene(Infinity);
|
||||
|
||||
this.frame = this.currentScene.firstFrame ?? 0;
|
||||
this.add(this.currentScene);
|
||||
this.foreground.moveToTop();
|
||||
await this.currentScene.reset();
|
||||
|
||||
let finished = false;
|
||||
while (!finished) {
|
||||
finished = await this.next(1);
|
||||
}
|
||||
}
|
||||
|
||||
public async seek(frame: number, speed: number = 1): Promise<boolean> {
|
||||
if (
|
||||
frame <= this.frame ||
|
||||
|
||||
@@ -124,7 +124,7 @@ export class Scene extends Layer {
|
||||
}
|
||||
|
||||
public canTransitionOut(): boolean {
|
||||
return this.state === SceneState.CanTransitionOut;
|
||||
return this.state === SceneState.CanTransitionOut || this.state === SceneState.Finished;
|
||||
}
|
||||
|
||||
public add(...children: (Shape | Group)[]): this {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Player} from './player/Player';
|
||||
|
||||
export function hot(player: Player, root: typeof module) {
|
||||
export function hot(player: Player, root: any) {
|
||||
const update = async (modules: string[]) => {
|
||||
const runners = [];
|
||||
for (const module of modules) {
|
||||
@@ -21,6 +21,7 @@ export function hot(player: Player, root: typeof module) {
|
||||
player.reload();
|
||||
};
|
||||
|
||||
//@ts-ignore
|
||||
const scenePaths = require.cache[root.id].children.filter(name =>
|
||||
//@ts-ignore
|
||||
name.match(/\.scene\.[jt]sx?/),
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import {Player, PlayerRenderEvent, PlayerState} from './Player';
|
||||
|
||||
export class Controls {
|
||||
private play: HTMLInputElement;
|
||||
private loop: HTMLInputElement;
|
||||
private audio: HTMLInputElement;
|
||||
private speed: HTMLInputElement;
|
||||
private fps: HTMLInputElement;
|
||||
private loadingIndicator: HTMLElement;
|
||||
private lastUpdate: number = 0;
|
||||
private updateTimes: number[] = [];
|
||||
private overallTime: number = 0;
|
||||
private directory: FileSystemDirectoryHandle;
|
||||
|
||||
public constructor(
|
||||
private readonly player: Player,
|
||||
private readonly form: HTMLFormElement,
|
||||
) {
|
||||
this.play = form.play;
|
||||
this.loop = form.loop;
|
||||
this.audio = form.audio;
|
||||
this.speed = form.speed;
|
||||
this.fps = form.fps;
|
||||
this.loadingIndicator = document.getElementById('loading');
|
||||
|
||||
this.play.addEventListener('change', () =>
|
||||
this.player.togglePlayback(this.play.checked),
|
||||
);
|
||||
this.loop.addEventListener('change', () =>
|
||||
this.player.updateState({loop: this.loop.checked}),
|
||||
);
|
||||
this.audio.addEventListener('change', () =>
|
||||
this.player.toggleAudio(this.audio.checked),
|
||||
);
|
||||
this.speed.addEventListener('change', () =>
|
||||
this.player.updateState({speed: parseFloat(this.speed.value)}),
|
||||
);
|
||||
|
||||
form.refresh.addEventListener('click', () => this.player.requestReset());
|
||||
form.next.addEventListener('click', () => this.player.requestNextFrame());
|
||||
form.render.addEventListener('click', () => this.player.toggleRendering());
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
switch (event.key) {
|
||||
case ' ':
|
||||
event.preventDefault();
|
||||
this.player.togglePlayback();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
event.preventDefault();
|
||||
this.player.requestNextFrame();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault();
|
||||
this.player.requestReset();
|
||||
break;
|
||||
case 'm':
|
||||
event.preventDefault();
|
||||
this.player.toggleAudio();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
this.player.StateChanged.sub(this.handleStateUpdate);
|
||||
this.player.RenderChanged.sub(this.handleRenderChange);
|
||||
}
|
||||
|
||||
public handleStateUpdate = (state: PlayerState) => {
|
||||
if (state.frame === state.startFrame || state.frame === 0) {
|
||||
this.overallTime = 0;
|
||||
this.updateTimes = [];
|
||||
}
|
||||
|
||||
this.updateFramerate(state.frame);
|
||||
this.play.checked = !state.paused;
|
||||
this.loop.checked = state.loop;
|
||||
this.audio.checked = !state.muted;
|
||||
this.speed.value = state.speed.toString();
|
||||
this.loadingIndicator.hidden = !state.loading;
|
||||
};
|
||||
|
||||
private previousFrame: number = 0;
|
||||
public updateFramerate(frame: number) {
|
||||
const passed = performance.now() - this.lastUpdate;
|
||||
|
||||
if (this.previousFrame === frame) return;
|
||||
this.previousFrame = frame;
|
||||
|
||||
this.overallTime += passed;
|
||||
this.updateTimes.push(passed);
|
||||
if (this.updateTimes.length > 10) {
|
||||
this.overallTime -= this.updateTimes.shift();
|
||||
}
|
||||
|
||||
const average = this.overallTime / this.updateTimes.length;
|
||||
this.fps.value = Math.floor(1000 / average).toString();
|
||||
|
||||
this.lastUpdate = performance.now();
|
||||
}
|
||||
|
||||
public handleRenderChange = async ({frame, blob}: PlayerRenderEvent) => {
|
||||
const name = frame.toString().padStart(6, '0');
|
||||
const size = blob.size / 1024;
|
||||
|
||||
try {
|
||||
this.directory ??= await window.showDirectoryPicker();
|
||||
const file = await this.directory.getFileHandle(`frame-${name}.png`, {
|
||||
create: true,
|
||||
});
|
||||
const stream = await file.createWritable();
|
||||
await stream.write(blob);
|
||||
await stream.close();
|
||||
console.log(`Frame: ${name}, Size: ${Math.round(size)} kB`);
|
||||
this.updateFramerate(frame);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.player.toggleRendering(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import {Grid} from '../components/Grid';
|
||||
import {Player} from './Player';
|
||||
import {Vector2d} from 'konva/lib/types';
|
||||
|
||||
const ZOOM_SPEED = 0.05;
|
||||
const STORAGE_KEY = 'navigator-state';
|
||||
|
||||
interface NavigatorState {
|
||||
position: Vector2d;
|
||||
scale: number;
|
||||
gridVisible: boolean;
|
||||
}
|
||||
|
||||
export class Navigator {
|
||||
private readonly state: NavigatorState = {
|
||||
position: {x: 0, y: 0},
|
||||
scale: 1,
|
||||
gridVisible: false,
|
||||
};
|
||||
|
||||
private isPanning = false;
|
||||
private startPosition = {x: 0, y: 0};
|
||||
private panStartPosition = {x: 0, y: 0};
|
||||
|
||||
private readonly grid: Grid;
|
||||
private gridZoom: number = null;
|
||||
|
||||
public constructor(
|
||||
private readonly player: Player,
|
||||
private readonly root: HTMLElement,
|
||||
) {
|
||||
const savedState = localStorage.getItem(STORAGE_KEY);
|
||||
if (savedState) {
|
||||
this.state = JSON.parse(savedState);
|
||||
}
|
||||
|
||||
this.grid = new Grid({
|
||||
...player.project.center,
|
||||
...player.project.size(),
|
||||
strokeWidth: 2,
|
||||
stroke: 'rgba(255, 255, 255, 0.32',
|
||||
subdivision: true,
|
||||
});
|
||||
player.project.foreground.add(this.grid);
|
||||
player.project.foreground.drawScene();
|
||||
|
||||
document.addEventListener('wheel', this.handleWheel);
|
||||
document.addEventListener('mousedown', this.handleMouseDown);
|
||||
document.addEventListener('mouseup', this.handleMouseUp);
|
||||
document.addEventListener('mousemove', this.handleMouseMove);
|
||||
document.addEventListener('keydown', event => {
|
||||
switch (event.key) {
|
||||
case '0':
|
||||
this.state.scale = 1;
|
||||
this.state.position = {x: 0, y: 0};
|
||||
this.update();
|
||||
break;
|
||||
case '=':
|
||||
this.state.scale *= 1 + ZOOM_SPEED;
|
||||
this.update();
|
||||
break;
|
||||
case '-':
|
||||
this.state.scale *= 1 - ZOOM_SPEED;
|
||||
this.update();
|
||||
break;
|
||||
case "'":
|
||||
this.state.gridVisible = !this.state.gridVisible;
|
||||
this.update();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
this.update();
|
||||
}
|
||||
|
||||
private handleWheel = (event: WheelEvent) => {
|
||||
if (this.isPanning) return;
|
||||
|
||||
const pointer = {
|
||||
x: event.x - document.body.offsetWidth / 2,
|
||||
y: event.y - document.body.offsetHeight / 2,
|
||||
};
|
||||
|
||||
const ratio = 1 - Math.sign(event.deltaY) * ZOOM_SPEED;
|
||||
this.state.scale *= ratio;
|
||||
|
||||
this.state.position = {
|
||||
x: pointer.x + (this.state.position.x - pointer.x) * ratio,
|
||||
y: pointer.y + (this.state.position.y - pointer.y) * ratio,
|
||||
};
|
||||
|
||||
this.update();
|
||||
};
|
||||
|
||||
private handleMouseDown = (event: MouseEvent) => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
this.isPanning = true;
|
||||
this.startPosition = {...this.state.position};
|
||||
this.panStartPosition = {x: event.x, y: event.y};
|
||||
}
|
||||
};
|
||||
|
||||
private handleMouseUp = (event: MouseEvent) => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
this.isPanning = false;
|
||||
}
|
||||
};
|
||||
|
||||
private handleMouseMove = (event: MouseEvent) => {
|
||||
if (this.isPanning) {
|
||||
this.state.position = {
|
||||
x: this.startPosition.x - this.panStartPosition.x + event.x,
|
||||
y: this.startPosition.y - this.panStartPosition.y + event.y,
|
||||
};
|
||||
this.update();
|
||||
}
|
||||
};
|
||||
|
||||
private update() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state));
|
||||
|
||||
this.root.style.transform = `translate(${this.state.position.x}px, ${this.state.position.y}px) scale(${this.state.scale})`;
|
||||
if (this.state.gridVisible) {
|
||||
const newZoom = Math.min(
|
||||
Math.pow(2, Math.round(Math.log2(this.state.scale))),
|
||||
2,
|
||||
);
|
||||
if (newZoom !== this.gridZoom) {
|
||||
this.gridZoom = newZoom;
|
||||
this.grid.gridSize(80 / newZoom);
|
||||
this.player.project.foreground.drawScene();
|
||||
}
|
||||
}
|
||||
if (this.grid.visible() !== this.state.gridVisible) {
|
||||
this.grid.visible(this.state.gridVisible);
|
||||
this.player.project.foreground.drawScene();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ const MAX_AUDIO_DESYNC = 1 / 50;
|
||||
|
||||
export interface PlayerState {
|
||||
duration: number;
|
||||
frame: number;
|
||||
startFrame: number;
|
||||
endFrame: number;
|
||||
paused: boolean;
|
||||
loading: boolean;
|
||||
finished: boolean;
|
||||
@@ -19,9 +19,18 @@ export interface PlayerState {
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
export interface PlayerTime {
|
||||
duration: number;
|
||||
durationTime: number;
|
||||
frame: number;
|
||||
completion: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
interface PlayerCommands {
|
||||
reset: boolean;
|
||||
seek: number;
|
||||
recalculate: boolean;
|
||||
}
|
||||
|
||||
export interface PlayerRenderEvent {
|
||||
@@ -36,11 +45,16 @@ export class Player {
|
||||
return this.stateChanged.asEvent();
|
||||
}
|
||||
|
||||
public get TimeChanged() {
|
||||
return this.timeChanged.asEvent();
|
||||
}
|
||||
|
||||
public get RenderChanged() {
|
||||
return this.renderChanged.asEvent();
|
||||
}
|
||||
|
||||
private readonly stateChanged = new SimpleEventDispatcher<PlayerState>();
|
||||
private readonly timeChanged = new SimpleEventDispatcher<PlayerTime>();
|
||||
private readonly renderChanged =
|
||||
new PromiseSimpleEventDispatcher<PlayerRenderEvent>();
|
||||
|
||||
@@ -50,10 +64,24 @@ export class Player {
|
||||
private requestId: number = null;
|
||||
private audioError = false;
|
||||
|
||||
public getState(): PlayerState {
|
||||
return {...this.state};
|
||||
}
|
||||
|
||||
public getTime(): PlayerTime {
|
||||
return {
|
||||
frame: this.frame,
|
||||
time: this.project.framesToSeconds(this.frame),
|
||||
duration: this.state.duration,
|
||||
durationTime: this.project.framesToSeconds(this.state.duration),
|
||||
completion: this.frame / this.state.duration,
|
||||
};
|
||||
}
|
||||
|
||||
private state: PlayerState = {
|
||||
duration: 100,
|
||||
frame: 0,
|
||||
duration: Infinity,
|
||||
startFrame: 0,
|
||||
endFrame: Infinity,
|
||||
paused: true,
|
||||
loading: false,
|
||||
finished: false,
|
||||
@@ -63,29 +91,56 @@ export class Player {
|
||||
muted: true,
|
||||
};
|
||||
|
||||
private frame: number = 0;
|
||||
|
||||
private commands: PlayerCommands = {
|
||||
reset: true,
|
||||
seek: -1,
|
||||
recalculate: true,
|
||||
};
|
||||
|
||||
public updateState(newState: Partial<PlayerState>) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
...newState,
|
||||
};
|
||||
this.stateChanged.dispatch(this.state);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state));
|
||||
let changed = false;
|
||||
for (const prop in newState) {
|
||||
if (prop === 'frame') {
|
||||
continue;
|
||||
}
|
||||
// @ts-ignore
|
||||
if (newState[prop] !== this.state[prop]) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
...newState,
|
||||
};
|
||||
this.stateChanged.dispatch(this.state);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state));
|
||||
}
|
||||
}
|
||||
|
||||
private updateFrame(value: number) {
|
||||
this.frame = value;
|
||||
this.timeChanged.dispatch(this.getTime());
|
||||
}
|
||||
|
||||
private consumeCommands(): PlayerCommands {
|
||||
const commands = {...this.commands};
|
||||
this.commands.reset = false;
|
||||
this.commands.seek = -1;
|
||||
this.commands.recalculate = false;
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
public constructor(public readonly project: Project, audioSrc?: string) {
|
||||
public constructor(
|
||||
public readonly project: Project,
|
||||
audioSrc?: string,
|
||||
public readonly labels?: Record<string, number>,
|
||||
) {
|
||||
this.startTime = performance.now();
|
||||
|
||||
const savedState = localStorage.getItem(STORAGE_KEY);
|
||||
@@ -93,6 +148,7 @@ export class Player {
|
||||
const state = JSON.parse(savedState) as PlayerState;
|
||||
this.state.paused = state.paused;
|
||||
this.state.startFrame = state.startFrame;
|
||||
this.state.endFrame = state.endFrame;
|
||||
this.state.loop = state.loop;
|
||||
this.state.speed = state.speed;
|
||||
this.state.muted = state.muted;
|
||||
@@ -100,26 +156,20 @@ export class Player {
|
||||
|
||||
if (audioSrc) {
|
||||
this.audio = new Audio(audioSrc);
|
||||
this.audio.addEventListener('durationchange', () => {
|
||||
this.updateState({
|
||||
duration: this.project.secondsToFrames(this.audio.duration),
|
||||
});
|
||||
this.requestSeek(this.state.duration);
|
||||
});
|
||||
}
|
||||
|
||||
this.request();
|
||||
}
|
||||
|
||||
public reload() {
|
||||
this.requestSeek(this.project.frame);
|
||||
this.commands.recalculate = true;
|
||||
if (this.requestId === null) {
|
||||
this.request();
|
||||
}
|
||||
}
|
||||
|
||||
public requestNextFrame(): void {
|
||||
this.commands.seek = this.state.frame + 1;
|
||||
this.commands.seek = this.frame + 1;
|
||||
}
|
||||
|
||||
public requestReset(): void {
|
||||
@@ -127,10 +177,10 @@ export class Player {
|
||||
}
|
||||
|
||||
public requestSeek(value: number): void {
|
||||
this.commands.seek = value;
|
||||
if (value < this.state.startFrame) {
|
||||
this.updateState({startFrame: value});
|
||||
}
|
||||
this.commands.seek = this.inRange(value, this.state);
|
||||
// if (value < this.state.startFrame) {
|
||||
// this.updateState({startFrame: value});
|
||||
// }
|
||||
}
|
||||
|
||||
public togglePlayback(value?: boolean): void {
|
||||
@@ -155,7 +205,24 @@ export class Player {
|
||||
let commands = this.consumeCommands();
|
||||
let state = {...this.state};
|
||||
if (state.finished && state.loop && commands.seek < 0) {
|
||||
commands.seek = 0;
|
||||
commands.seek = state.startFrame;
|
||||
}
|
||||
|
||||
// Recalculate
|
||||
if (commands.recalculate) {
|
||||
await this.project.recalculate();
|
||||
const duration = this.project.frame;
|
||||
const finished = await this.project.seek(this.frame);
|
||||
this.project.draw();
|
||||
this.updateState({
|
||||
duration,
|
||||
finished,
|
||||
});
|
||||
if (this.frame + 1 !== this.project.frame) {
|
||||
this.updateFrame(this.project.frame);
|
||||
}
|
||||
this.request();
|
||||
return;
|
||||
}
|
||||
|
||||
// Pause / play audio.
|
||||
@@ -185,23 +252,24 @@ export class Player {
|
||||
frame: this.project.frame,
|
||||
blob: await this.getContent(),
|
||||
});
|
||||
if (state.finished) {
|
||||
if (state.finished || this.project.frame >= state.endFrame) {
|
||||
state.render = false;
|
||||
}
|
||||
|
||||
this.updateState({
|
||||
finished: state.finished,
|
||||
render: state.render,
|
||||
frame: this.project.frame,
|
||||
});
|
||||
this.updateFrame(this.project.frame);
|
||||
this.request();
|
||||
return;
|
||||
}
|
||||
|
||||
// Seek to the given frame
|
||||
if (commands.seek >= 0 || this.project.frame < state.startFrame) {
|
||||
const seekFrame = Math.max(commands.seek, state.startFrame);
|
||||
state.finished = await this.project.seek(seekFrame, state.speed);
|
||||
if (commands.seek >= 0 || !this.isInRange(this.project.frame, state)) {
|
||||
const seekFrame = commands.seek < 0 ? this.project.frame : commands.seek;
|
||||
const clampedFrame = this.inRange(seekFrame, state);
|
||||
state.finished = await this.project.seek(clampedFrame, state.speed);
|
||||
this.syncAudio(-3);
|
||||
}
|
||||
// Do nothing if paused or is ahead of the audio.
|
||||
@@ -224,7 +292,7 @@ export class Player {
|
||||
state.finished = await this.project.seek(seekFrame, state.speed);
|
||||
}
|
||||
// Simply move forward one frame
|
||||
else {
|
||||
else if (this.project.frame < state.endFrame) {
|
||||
state.finished = await this.project.next(state.speed);
|
||||
|
||||
// Synchronize audio.
|
||||
@@ -238,21 +306,31 @@ export class Player {
|
||||
|
||||
// handle finishing
|
||||
if (state.finished) {
|
||||
state.duration = this.project.frame;
|
||||
if (commands.seek >= 0) {
|
||||
this.requestSeek(state.startFrame);
|
||||
}
|
||||
}
|
||||
|
||||
this.updateState({
|
||||
finished: state.finished,
|
||||
duration: state.duration,
|
||||
frame: this.project.frame,
|
||||
finished: state.finished || this.project.frame >= state.endFrame,
|
||||
});
|
||||
this.updateFrame(this.project.frame);
|
||||
|
||||
this.request();
|
||||
}
|
||||
|
||||
private inRange(frame: number, state: PlayerState): number {
|
||||
return frame > state.endFrame
|
||||
? state.endFrame
|
||||
: frame < state.startFrame
|
||||
? state.startFrame
|
||||
: frame;
|
||||
}
|
||||
|
||||
private isInRange(frame: number, state: PlayerState): boolean {
|
||||
return frame >= state.startFrame && frame <= state.endFrame;
|
||||
}
|
||||
|
||||
private hasAudio(): boolean {
|
||||
return this.audio && !this.audioError;
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import {GeneratorHelper} from '../helpers';
|
||||
import {Player} from './Player';
|
||||
import {Thread} from '../threading';
|
||||
|
||||
const STORAGE_KEY = 'threads-monitor-state';
|
||||
|
||||
export class ThreadsMonitor {
|
||||
private list = document.createElement('ul');
|
||||
|
||||
public constructor(
|
||||
private readonly player: Player,
|
||||
private readonly root: HTMLDetailsElement,
|
||||
) {
|
||||
const savedState = localStorage.getItem(STORAGE_KEY);
|
||||
if (savedState) {
|
||||
this.root.open = JSON.parse(savedState);
|
||||
}
|
||||
|
||||
this.player.project.threadsCallback = this.render;
|
||||
this.root.addEventListener('toggle', this.handleToggle);
|
||||
this.root.appendChild(this.list);
|
||||
}
|
||||
|
||||
private handleToggle = () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.root.open));
|
||||
};
|
||||
|
||||
private render = (rootThread: Thread) => {
|
||||
if (!this.root.open) return;
|
||||
|
||||
this.list.firstElementChild?.remove();
|
||||
const queue: [Thread, HTMLElement][] = [[rootThread, this.list]];
|
||||
while (queue.length > 0) {
|
||||
const [thread, parent] = queue.shift();
|
||||
const element = this.createRunnerElement(thread);
|
||||
parent.appendChild(element);
|
||||
for (const child of thread.children) {
|
||||
queue.push([child, element]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private createRunnerElement(thread: Thread): HTMLElement {
|
||||
const element = document.createElement('ul');
|
||||
const title = document.createElement('li');
|
||||
title.innerText = GeneratorHelper.getName(thread.runner);
|
||||
element.appendChild(title);
|
||||
element.classList.toggle('cancelled', thread.canceled);
|
||||
|
||||
return element;
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import {clampRemap} from '../tweening';
|
||||
import {Player, PlayerState} from './Player';
|
||||
import {clamp} from 'three/src/math/MathUtils';
|
||||
|
||||
export class Timeline {
|
||||
private readonly fillTime: HTMLElement;
|
||||
private readonly fillSeek: HTMLElement;
|
||||
private readonly fillStart: HTMLElement;
|
||||
private readonly track: HTMLElement;
|
||||
private readonly marker: HTMLElement;
|
||||
private readonly timeText: HTMLElement;
|
||||
private readonly durationText: HTMLElement;
|
||||
|
||||
private duration: number;
|
||||
private labelElements: Record<string, HTMLElement> = {};
|
||||
private lastState: PlayerState;
|
||||
|
||||
public constructor(
|
||||
private readonly player: Player,
|
||||
private readonly root: HTMLElement,
|
||||
private readonly labels: Record<string, number>,
|
||||
) {
|
||||
this.fillTime = root.querySelector('.fill-time')!;
|
||||
this.fillSeek = root.querySelector('.fill-seek')!;
|
||||
this.fillStart = root.querySelector('.fill-start')!;
|
||||
this.timeText = root.querySelector('.js-current-time')!;
|
||||
this.durationText = root.querySelector('.js-duration')!;
|
||||
this.track = root.querySelector('.track')!;
|
||||
this.marker = root.querySelector('.marker')!;
|
||||
|
||||
this.player.StateChanged.sub(this.update);
|
||||
this.root.addEventListener('click', e => {
|
||||
const target = <HTMLElement>e.target;
|
||||
if (target === this.timeText) {
|
||||
this.player.updateState({startFrame: this.lastState.frame});
|
||||
return;
|
||||
}
|
||||
this.player.requestSeek(
|
||||
target.classList.contains('label')
|
||||
? parseFloat(target.dataset.time!)
|
||||
: this.mousePositionToFrame(e.clientX),
|
||||
);
|
||||
});
|
||||
this.root.addEventListener('contextmenu', e => {
|
||||
e.preventDefault();
|
||||
this.player.updateState({
|
||||
startFrame: this.mousePositionToFrame(e.clientX),
|
||||
});
|
||||
});
|
||||
this.root.addEventListener('mousemove', e => {
|
||||
const target = <HTMLElement>e.target;
|
||||
const rect = this.track.getBoundingClientRect();
|
||||
let x = clamp(e.clientX - rect.left, 8, rect.width);
|
||||
|
||||
if (target.classList.contains('label')) {
|
||||
const frame = parseInt(target.dataset.time!);
|
||||
x = (frame / this.duration) * rect.width;
|
||||
}
|
||||
this.fillSeek.style.width = `${x}px`;
|
||||
});
|
||||
|
||||
for (const label in labels) {
|
||||
const element = document.createElement('div');
|
||||
element.classList.add('label');
|
||||
element.dataset.title = label;
|
||||
element.dataset.time = this.player.project
|
||||
.secondsToFrames(labels[label])
|
||||
.toString();
|
||||
this.track.appendChild(element);
|
||||
this.labelElements[label] = element;
|
||||
}
|
||||
}
|
||||
|
||||
private mousePositionToFrame(position: number) {
|
||||
const rect = this.track.getBoundingClientRect();
|
||||
const x = position - rect.left;
|
||||
return Math.floor(clampRemap(0, rect.width, 0, this.duration, x));
|
||||
}
|
||||
|
||||
private update = (state: PlayerState) => {
|
||||
this.lastState = {...state};
|
||||
this.timeText.innerText = state.frame.toString();
|
||||
this.marker.dataset.title = `Frame:${state.startFrame}`;
|
||||
|
||||
const width = this.track.clientWidth;
|
||||
const fillWidth = clampRemap(1, state.duration, 8, width, state.frame);
|
||||
const startWidth = clampRemap(
|
||||
1,
|
||||
state.duration,
|
||||
8,
|
||||
width,
|
||||
state.startFrame,
|
||||
);
|
||||
|
||||
this.marker.style.left = `${startWidth - 8}px`;
|
||||
this.fillStart.style.width = `${startWidth}px`;
|
||||
this.fillTime.style.width = `${fillWidth}px`;
|
||||
|
||||
this.durationText.innerText = state.duration.toString();
|
||||
this.duration = state.duration;
|
||||
for (const label in this.labels) {
|
||||
const element = this.labelElements[label];
|
||||
const time = parseInt(element.dataset.time);
|
||||
const elementLeft = clampRemap(1, state.duration, 8, width, time) - 4;
|
||||
element.style.left = `${elementLeft - 20}px`;
|
||||
element.classList.toggle('hidden', time > state.duration);
|
||||
element.classList.toggle('inverted', time > state.startFrame);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -9,14 +9,38 @@ const projectFile = path.resolve(process.cwd(), process.argv[2]);
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const compiler = webpack({
|
||||
entry: projectFile,
|
||||
entry: {
|
||||
index: projectFile,
|
||||
ui: path.resolve(__dirname, '../../ui/src/index.ts'),
|
||||
},
|
||||
mode: 'development',
|
||||
devtool: 'inline-source-map',
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.scss$/,
|
||||
use: [
|
||||
{loader: 'style-loader'},
|
||||
{loader: 'css-loader', options: {modules: true}},
|
||||
{loader: 'sass-loader'},
|
||||
],
|
||||
},
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
include: path.resolve(__dirname, '../../ui/'),
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
configFile: path.resolve(__dirname, '../../ui/tsconfig.json'),
|
||||
instance: 'ui',
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
exclude: path.resolve(__dirname, '../../ui/'),
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
instance: 'project',
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.glsl$/i,
|
||||
@@ -59,13 +83,17 @@ const compiler = webpack({
|
||||
modules: ['node_modules', path.resolve(__dirname, '../node_modules')],
|
||||
extensions: ['.js', '.ts', '.tsx'],
|
||||
alias: {
|
||||
MC: path.resolve(__dirname, '../dist'),
|
||||
'@motion-canvas/core': path.resolve(__dirname, '../dist'),
|
||||
'@motion-canvas/ui': path.resolve(__dirname, '../../ui/dist'),
|
||||
MC: path.resolve(__dirname, '../src'),
|
||||
'@motion-canvas/core': path.resolve(__dirname, '../src'),
|
||||
},
|
||||
},
|
||||
optimization: {
|
||||
runtimeChunk: {
|
||||
name: 'runtime',
|
||||
},
|
||||
},
|
||||
output: {
|
||||
filename: `index.js`,
|
||||
filename: `[name].js`,
|
||||
path: __dirname,
|
||||
},
|
||||
experiments: {
|
||||
|
||||
@@ -11,10 +11,8 @@
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@motion-canvas/core",
|
||||
"paths": {
|
||||
"MC/*": ["../../core/dist/*"],
|
||||
"@motion-canvas/core/*": ["../../core/dist/*"],
|
||||
"@motion-canvas/ui": ["../../ui/dist"],
|
||||
"@motion-canvas/ui/*": ["../../ui/dist/*"],
|
||||
"MC/*": ["../../core/src/*"],
|
||||
"@motion-canvas/core/*": ["../../core/src/*"],
|
||||
"*": ["../../core/node_modules/*", "../../core/node_modules/@types/*"]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user