add playback

This commit is contained in:
Enrico Bühler 2023-05-25 21:28:11 +02:00
parent 60c8bb5877
commit b671f9ee47
16 changed files with 417 additions and 163 deletions

View File

@ -1,51 +1,22 @@
import { FC, useMemo } from "react";
import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api";
import { useTimelineStore } from "stores/timeline.store";
import InitCanvasKit, { CanvasKit } from "canvaskit-wasm";
import { Surface } from "canvaskit-wasm";
import drawText from "drawers/text";
import drawRect from "drawers/rect";
import { Entities, EntityType } from "primitives/Entities";
import drawEllipse from "drawers/ellipse";
import { useRenderStateStore } from "stores/render-state.store";
import { useEntitiesStore } from "stores/entities.store";
import { AnimatedEntities } from "primitives/AnimatedEntities";
import drawStaggeredText, {
StaggeredTextCache,
calculateLetters,
} from "drawers/staggered-text";
import useMap from "hooks/useMap";
import { Drawer } from "drawers/draw";
import { PlaybackService } from "services/playback.service";
type CanvasProps = {};
function typedArrayToBuffer(array: Uint8Array): ArrayBuffer {
return array.buffer.slice(
array.byteOffset,
array.byteLength + array.byteOffset
);
}
const CanvasComponent: FC<CanvasProps> = () => {
const canvas = useRef<HTMLCanvasElement>(null);
const [didInit, setDidInit] = useState(false);
const renderState = useRenderStateStore((store) => store.renderState);
const { fps, size, duration } = useTimelineStore((store) => ({
fps: store.fps,
size: store.size,
duration: store.duration,
}));
const { entities, updateEntityById } = useEntitiesStore((store) => ({
entities: store.entities,
updateEntityById: store.updateEntityById,
}));
const drawer = useMemo(() => new Drawer(), []);
const playbackService = useMemo(() => new PlaybackService(), []);
useEffect(() => {
if (canvas.current && !didInit) {
drawer
playbackService
.init(canvas.current)
.then(() => {
setDidInit(true);
@ -54,12 +25,6 @@ const CanvasComponent: FC<CanvasProps> = () => {
}
}, []);
useEffect(() => {
if (didInit) {
drawer.update(entities);
}
}, [entities, renderState.curr_frame, didInit]);
return (
<div>
<div

View File

@ -1,20 +1,23 @@
import { FC } from "react";
import * as Slider from "@radix-ui/react-slider";
import { useRenderStateStore } from "stores/render-state.store";
import { TIMELINE_SCALE } from "./Timeline";
import { useTimelineStore } from "stores/timeline.store";
export type TimePickerProps = {};
const TimePicker: FC<TimePickerProps> = () => {
const { renderState, setCurrentFrame } = useRenderStateStore();
const timeline = useTimelineStore();
return (
<Slider.Root
className="relative flex select-none h-5 w-full items-center"
defaultValue={[50]}
style={{ width: 100 * 10 }}
style={{ width: TIMELINE_SCALE * timeline.duration }}
value={[renderState.curr_frame]}
onValueChange={(val) => setCurrentFrame(val[0])}
max={60 * 10}
max={timeline.fps * timeline.duration}
step={1}
aria-label="Current Frame"
>

View File

@ -1,7 +1,7 @@
import { FC } from "react";
import { z } from "zod";
import { AnimationData } from "primitives/AnimatedEntities";
import { motion } from "framer-motion";
import { AnimatedEntity, AnimationData } from "primitives/AnimatedEntities";
import { Reorder, motion, useDragControls } from "framer-motion";
import TimePicker from "./TimePicker";
import { shallow } from "zustand/shallow";
import { useEntitiesStore } from "stores/entities.store";
@ -9,6 +9,8 @@ import { ease } from "@unom/style";
import Timestamp from "./Timestamp";
import { Keyframe } from "primitives/Keyframe";
import { flattenedKeyframesByEntity } from "utils";
import { PauseIcon, PlayIcon } from "@radix-ui/react-icons";
import { useRenderStateStore } from "stores/render-state.store";
export type AnimationEntity = {
offset: number;
@ -17,10 +19,13 @@ export type AnimationEntity = {
type TimelineProps = {};
export const TIMELINE_SCALE = 50;
type TrackProps = {
animationData: z.input<typeof AnimationData>;
name: string;
index: number;
entity: z.input<typeof AnimatedEntity>;
keyframes: Array<z.input<typeof Keyframe>>;
};
@ -31,7 +36,7 @@ const KeyframeIndicator: FC<{
return (
<motion.div
animate={{
x: (animationData.offset + keyframe.offset) * 100 + 4,
x: (animationData.offset + keyframe.offset) * TIMELINE_SCALE + 4,
}}
transition={ease.quint(0.4).out}
style={{
@ -42,7 +47,15 @@ const KeyframeIndicator: FC<{
);
};
const Track: FC<TrackProps> = ({ keyframes, animationData, index, name }) => {
const Track: FC<TrackProps> = ({
keyframes,
animationData,
index,
name,
entity,
}) => {
const controls = useDragControls();
const { updateEntity, selectEntity, selectedEntity, deselectEntity } =
useEntitiesStore(
(store) => ({
@ -55,22 +68,34 @@ const Track: FC<TrackProps> = ({ keyframes, animationData, index, name }) => {
);
return (
<div className="h-8 w-100 flex flex-row gap-1 select-none">
<Reorder.Item
value={entity}
dragListener={false}
dragControls={controls}
className="h-8 w-full flex flex-row gap-1 select-none"
>
<div
onClick={() =>
selectedEntity !== undefined && selectedEntity === index
? deselectEntity()
: selectEntity(index)
}
onMouseDown={(e) => e.preventDefault()}
onPointerDown={(e) => controls.start(e)}
className={`h-full transition-all rounded-sm flex-shrink-0 w-96 p-1 px-2 flex flex-row ${
selectedEntity === index ? "bg-gray-800" : "bg-gray-900"
}`}
>
<h3 className="text-white-800">{name}</h3>
<h3
onClick={() =>
selectedEntity !== undefined && selectedEntity === index
? deselectEntity()
: selectEntity(index)
}
className="text-white-800 select-none pointer-events-none"
>
{name}
</h3>
</div>
<div
style={{ width: "1000px" }}
className="flex w-full h-full flex-row relative bg-gray-900 select-none"
className="flex h-full flex-row relative bg-gray-900 select-none"
>
{keyframes.map((keyframe, index) => (
<KeyframeIndicator
@ -82,7 +107,7 @@ const Track: FC<TrackProps> = ({ keyframes, animationData, index, name }) => {
<motion.div
drag="x"
animate={{
x: animationData.offset * 100,
x: animationData.offset * TIMELINE_SCALE,
}}
whileHover={{
scale: 1.1,
@ -120,7 +145,9 @@ const Track: FC<TrackProps> = ({ keyframes, animationData, index, name }) => {
onMouseDown={(e) => e.preventDefault()}
drag="x"
animate={{
x: (animationData.duration + animationData.offset) * 100 - 16,
x:
(animationData.duration + animationData.offset) * TIMELINE_SCALE -
16,
}}
whileHover={{
scale: 1.1,
@ -149,8 +176,8 @@ const Track: FC<TrackProps> = ({ keyframes, animationData, index, name }) => {
<motion.div
drag="x"
animate={{
width: animationData.duration * 100,
x: animationData.offset * 100,
width: animationData.duration * TIMELINE_SCALE,
x: animationData.offset * TIMELINE_SCALE,
}}
whileHover={{ scaleY: 1.1 }}
whileTap={{ scaleY: 0.9 }}
@ -174,33 +201,54 @@ const Track: FC<TrackProps> = ({ keyframes, animationData, index, name }) => {
className="z-5 h-full absolute rounded-md transition-colors bg-gray-700 hover:bg-gray-600 select-none cursor-grab"
></motion.div>
</div>
</div>
</Reorder.Item>
);
};
const Timeline: FC<TimelineProps> = () => {
const { entities } = useEntitiesStore((store) => ({
const { entities, setEntities } = useEntitiesStore((store) => ({
entities: store.entities,
setEntities: store.setEntities,
}));
const { setPlaying } = useRenderStateStore((store) => ({
setPlaying: store.setPlaying,
}));
return (
<div className="flex flex-col p-4 border transition-colors focus-within:border-gray-400 border-gray-600 rounded-md">
<Timestamp />
<div className="gap-1 flex flex-col overflow-hidden">
<div className="flex flex-col p-4 w-full border transition-colors focus-within:border-gray-400 border-gray-600 rounded-md">
<div className="flex flex-row">
<div className="flex flex-row">
<button onClick={() => setPlaying(true)} className="w-8 h-8">
<PlayIcon color="white" width="100%" height="100%" />
</button>
<button onClick={() => setPlaying(false)} className="w-8 h-8">
<PauseIcon color="white" width="100%" height="100%" />
</button>
</div>
<Timestamp />
</div>
<div className="gap-1 flex flex-col overflow-y-hidden">
<div className="z-20 flex flex-row gap-2">
<div className="flex-shrink-0 w-96" />
<TimePicker />
</div>
{entities.map((entity, index) => (
<Track
name={entity.type}
index={index}
key={index}
keyframes={flattenedKeyframesByEntity(entity)}
animationData={entity.animation_data}
/>
))}
<Reorder.Group
className="gap-1 flex flex-col"
values={entities}
onReorder={setEntities}
>
{entities.map((entity, index) => (
<Track
entity={entity}
key={entity.id}
name={entity.type}
index={index}
keyframes={flattenedKeyframesByEntity(entity)}
animationData={entity.animation_data}
/>
))}
</Reorder.Group>
</div>
</div>
);

View File

@ -7,9 +7,12 @@ const Timestamp = () => {
return (
<div>
<h3>Frame {renderState.curr_frame}</h3>
<h3>
Frame {renderState.curr_frame} / {timeline.fps * timeline.duration}
</h3>
<h2 className="text-xl font-bold">
{((renderState.curr_frame * timeline.fps) / 60 / 60).toPrecision(3)}{" "}
{(renderState.curr_frame / timeline.fps).toPrecision(3)} /{" "}
{timeline.duration.toPrecision(3)}
<span className="text-sm font-light">/ {timeline.fps}FPS</span>
</h2>
</div>

View File

@ -1,7 +1,12 @@
import { invoke } from "@tauri-apps/api";
import InitCanvasKit, { Canvas, CanvasKit, Surface } from "canvaskit-wasm";
import { AnimatedEntities } from "primitives/AnimatedEntities";
import { Entities, EntityType, StaggeredText } from "primitives/Entities";
import {
Entities,
EntityType,
StaggeredTextEntity,
TextEntity,
} from "primitives/Entities";
import { useRenderStateStore } from "stores/render-state.store";
import { useTimelineStore } from "stores/timeline.store";
import { z } from "zod";
@ -10,18 +15,13 @@ import drawStaggeredText, {
StaggeredTextEntityCache,
calculateLetters,
} from "./staggered-text";
import drawText from "./text";
import drawText, { TextCache, TextEntityCache, buildTextCache } from "./text";
import drawEllipse from "./ellipse";
import drawRect from "./rect";
import { useEntitiesStore } from "stores/entities.store";
import { handleEntityCache } from "./cache";
function typedArrayToBuffer(array: Uint8Array): ArrayBuffer {
return array.buffer.slice(
array.byteOffset,
array.byteLength + array.byteOffset
);
}
import { DependenciesService } from "services/dependencies.service";
import { RenderState } from "primitives/Timeline";
/**
*
@ -32,36 +32,39 @@ export class Drawer {
private didLoad: boolean;
private entities: z.output<typeof Entities> | undefined;
private ckDidLoad: boolean;
private dependenciesDidLoad: boolean;
drawCount: number;
private CanvasKit: CanvasKit | undefined;
cache: { staggeredText: Map<string, StaggeredTextCache> };
cache: {
staggeredText: Map<string, StaggeredTextCache>;
text: Map<string, TextCache>;
};
surface: Surface | undefined;
fontData: ArrayBuffer | undefined;
raf: number | undefined;
isLocked: boolean;
dependenciesService: DependenciesService;
constructor() {
this.entities = undefined;
this.CanvasKit = undefined;
this.ckDidLoad = false;
this.dependenciesDidLoad = false;
this.drawCount = 0;
this.surface = undefined;
this.fontData = undefined;
this.cache = {
staggeredText: new Map(),
text: new Map(),
};
this.dependenciesService = new DependenciesService();
this.isLocked = false;
this.raf = undefined;
this.didLoad = this.ckDidLoad && this.dependenciesDidLoad;
this.didLoad = this.ckDidLoad;
}
async init(canvas: HTMLCanvasElement) {
await this.loadCanvasKit(canvas);
await this.loadDependencies(false);
this.didLoad = this.ckDidLoad && this.dependenciesDidLoad;
this.didLoad = this.ckDidLoad;
}
async loadCanvasKit(canvas: HTMLCanvasElement) {
@ -80,66 +83,69 @@ export class Drawer {
});
}
async loadDependencies(remote: boolean) {
if (remote) {
await fetch(
"https://storage.googleapis.com/skia-cdn/misc/Roboto-Regular.ttf"
)
.then((response) => response.arrayBuffer())
.then((arrayBuffer) => {
this.fontData = arrayBuffer;
this.dependenciesDidLoad = true;
});
} else {
await invoke("get_system_font", { fontName: "Helvetica-Bold" }).then(
(data) => {
if (Array.isArray(data)) {
const u8 = new Uint8Array(data as any);
const buffer = typedArrayToBuffer(u8);
this.fontData = buffer;
this.dependenciesDidLoad = true;
}
}
async calculateAnimatedEntities(
animatedEntities: z.input<typeof AnimatedEntities>,
renderState: z.output<typeof RenderState>
) {
const { fps, size, duration } = useTimelineStore.getState();
const parsedAnimatedEntities = AnimatedEntities.parse(animatedEntities);
const data = await invoke("calculate_timeline_entities_at_frame", {
timeline: {
entities: parsedAnimatedEntities,
render_state: renderState,
fps,
size,
duration,
},
});
const parsedEntities = Entities.parse(data);
return parsedEntities;
}
get isCached(): boolean {
if (this.entities) {
return this.entities.reduce(
(prev, curr) => prev && curr.cache.valid,
true
);
} else {
return false;
}
}
/**
* Updates the entities based on the input
*/
update(animatedEntities: z.input<typeof AnimatedEntities>) {
update(
animatedEntities: z.input<typeof AnimatedEntities>,
prepareDependencies: boolean
) {
console.time("calculate");
const parsedAnimatedEntities = AnimatedEntities.parse(animatedEntities);
if (this.didLoad) {
const render_state = useRenderStateStore.getState().renderState;
const { fps, size, duration } = useTimelineStore.getState();
const renderState = useRenderStateStore.getState().renderState;
invoke("calculate_timeline_entities_at_frame", {
timeline: {
entities: parsedAnimatedEntities,
render_state,
fps,
size,
duration,
},
}).then((data) => {
console.timeEnd("calculate");
const parsedEntities = Entities.safeParse(data);
if (parsedEntities.success) {
this.entities = parsedEntities.data;
this.calculateAnimatedEntities(animatedEntities, renderState).then(
(entities) => {
this.entities = entities;
const isCached = this.entities.reduce(
(prev, curr) => prev && curr.cache.valid,
true
);
this.requestRedraw(!isCached);
} else {
console.error(parsedEntities.error);
if (prepareDependencies) {
this.dependenciesService
.prepareForEntities(this.entities)
.then(() => {
this.requestRedraw(!this.isCached);
});
} else {
this.requestRedraw(!this.isCached);
}
}
});
);
} else {
console.timeEnd("calculate");
}
}
@ -161,11 +167,10 @@ export class Drawer {
}
draw(canvas: Canvas) {
if (this.CanvasKit && this.entities && this.fontData && !this.isLocked) {
if (this.CanvasKit && this.entities && !this.isLocked) {
this.isLocked = true;
console.time("draw");
const CanvasKit = this.CanvasKit;
const fontData = this.fontData;
canvas.clear(CanvasKit.WHITE);
@ -180,17 +185,42 @@ export class Drawer {
drawEllipse(CanvasKit, canvas, entity);
break;
case EntityType.Enum.Text:
drawText(CanvasKit, canvas, entity, fontData);
{
const cache = handleEntityCache<
z.output<typeof TextEntity>,
TextCache,
TextEntityCache
>(entity, {
build: () =>
buildTextCache(
CanvasKit,
entity,
this.dependenciesService.dependencies
),
get: () => this.cache.text.get(entity.id),
set: (id, cache) => this.cache.text.set(id, cache),
cleanup: (cache) => {
cache.fontManager.delete();
},
});
drawText(CanvasKit, canvas, entity, cache);
}
break;
case EntityType.Enum.StaggeredText:
{
const cache = handleEntityCache<
z.output<typeof StaggeredText>,
z.output<typeof StaggeredTextEntity>,
StaggeredTextCache,
StaggeredTextEntityCache
>(entity, {
build: () => {
const cache = calculateLetters(CanvasKit, entity, fontData);
const cache = calculateLetters(
CanvasKit,
entity,
this.dependenciesService.dependencies
);
useEntitiesStore
.getState()
.updateEntityById(entity.id, { cache: { valid: true } });

View File

View File

@ -7,10 +7,11 @@ import {
TypedArray,
Typeface,
} from "canvaskit-wasm";
import { StaggeredText } from "primitives/Entities";
import { StaggeredTextEntity } from "primitives/Entities";
import { z } from "zod";
import { buildPaintStyle } from "./paint";
import { EntityCache } from "./cache";
import { Dependencies } from "services/dependencies.service";
export type StaggeredTextCache = {
letterMeasures: Array<LetterMeasures>;
@ -94,9 +95,13 @@ type LetterMeasures = {
export function calculateLetters(
CanvasKit: CanvasKit,
entity: z.output<typeof StaggeredText>,
fontData: ArrayBuffer
entity: z.output<typeof StaggeredTextEntity>,
dependencies: Dependencies
): StaggeredTextCache {
const fontData = dependencies.fonts.get(
entity.letter.paint.fontName
) as ArrayBuffer;
const typeface = CanvasKit.Typeface.MakeFreeTypeFaceFromData(
fontData
) as Typeface;
@ -189,7 +194,7 @@ export function calculateLetters(
export default function drawStaggeredText(
CanvasKit: CanvasKit,
canvas: Canvas,
entity: z.output<typeof StaggeredText>,
entity: z.output<typeof StaggeredTextEntity>,
cache: StaggeredTextCache
) {
const paint = new CanvasKit.Paint();

View File

@ -1,46 +1,56 @@
import { Canvas, CanvasKit, Font } from "canvaskit-wasm";
import { Canvas, CanvasKit, Font, FontMgr, Typeface } from "canvaskit-wasm";
import { TextEntity } from "primitives/Entities";
import { convertToFloat } from "@tempblade/common";
import { z } from "zod";
import { EntityCache } from "./cache";
import { Dependencies } from "services/dependencies.service";
import { buildPaintStyle } from "./paint";
export type TextCache = {
font: Font;
fontManager: FontMgr;
};
export type TextEntityCache = EntityCache<TextCache>;
export function buildTextCache(
CanvasKit: CanvasKit,
entity: z.output<typeof TextEntity>,
dependencies: Dependencies
): TextCache {
const fontData = dependencies.fonts.get(entity.paint.fontName) as ArrayBuffer;
const fontManager = CanvasKit.FontMgr.FromData(fontData) as FontMgr;
return {
fontManager,
};
}
export default function drawText(
CanvasKit: CanvasKit,
canvas: Canvas,
entity: z.output<typeof TextEntity>,
fontData: ArrayBuffer
cache: TextCache
) {
canvas.save();
const fontMgr = CanvasKit.FontMgr.FromData(fontData);
if (!fontMgr) {
console.error("No FontMgr");
return;
}
const paint = new CanvasKit.Paint();
const color = convertToFloat(entity.paint.style.color.value);
paint.setColor(color);
buildPaintStyle(CanvasKit, paint, entity.paint);
const pStyle = new CanvasKit.ParagraphStyle({
textStyle: {
color: color,
fontFamilies: ["Helvetica"],
fontFamilies: [entity.paint.fontName],
fontSize: entity.paint.size,
},
textDirection: CanvasKit.TextDirection.LTR,
textAlign: CanvasKit.TextAlign[entity.paint.align],
});
const builder = CanvasKit.ParagraphBuilder.Make(pStyle, fontMgr);
const builder = CanvasKit.ParagraphBuilder.Make(pStyle, cache.fontManager);
builder.addText(entity.text);
const p = builder.build();
p.layout(900);
@ -49,7 +59,7 @@ export default function drawText(
canvas.drawParagraph(p, entity.origin[0] - width, entity.origin[1] - height);
paint.delete();
canvas.restore();
builder.delete();
}

View File

@ -300,7 +300,7 @@ const ExampleTimeline: z.input<typeof Timeline> = {
render_state: {
curr_frame: 20,
},
fps: 60,
fps: 120,
entities: EXAMPLE_ANIMATED_ENTITIES,
};

View File

@ -26,7 +26,7 @@ export const GeometryEntity = BaseEntity.extend({
paint: Paint,
});
export const StaggeredText = BaseEntity.extend({
export const StaggeredTextEntity = BaseEntity.extend({
letter: z.object({
transform: z.array(Transform).optional(),
paint: TextPaint,
@ -64,7 +64,7 @@ export const Entity = z.discriminatedUnion("type", [
RectEntity,
EllipseEntity,
TextEntity,
StaggeredText,
StaggeredTextEntity,
]);
export const Entities = z.array(Entity);

View File

@ -35,6 +35,7 @@ export const Paint = z.object({
export const TextPaint = z.object({
style: PaintStyle,
align: TextAlign,
fontName: z.string().default("Helvetica-Bold"),
size: z.number().min(0),
});

View File

@ -0,0 +1,83 @@
import { invoke } from "@tauri-apps/api";
import { AnimatedEntities } from "primitives/AnimatedEntities";
import { Entities, Entity, EntityType } from "primitives/Entities";
import { z } from "zod";
function typedArrayToBuffer(array: Uint8Array): ArrayBuffer {
return array.buffer.slice(
array.byteOffset,
array.byteLength + array.byteOffset
);
}
export type Dependencies = {
fonts: Map<string, ArrayBuffer>;
};
export class DependenciesService {
dependencies: Dependencies;
constructor() {
this.dependencies = {
fonts: new Map(),
};
}
private async prepare(
entities: z.output<typeof Entities> | z.output<typeof AnimatedEntities>
) {
const fontNames = new Set<string>();
entities.forEach((entity) => {
if (entity.type === EntityType.Enum.Text) {
}
switch (entity.type) {
case EntityType.Enum.Text:
fontNames.add(entity.paint.fontName);
break;
case EntityType.Enum.StaggeredText:
fontNames.add(entity.letter.paint.fontName);
break;
default:
break;
}
});
await this.loadFonts(fontNames);
return this.dependencies;
}
async prepareForEntities(entities: z.output<typeof Entities>) {
await this.prepare(entities);
}
async prepareForAnimatedEntities(
animatedEntities: z.output<typeof AnimatedEntities>
) {
await this.prepare(animatedEntities);
}
async loadFonts(fontNames: Set<string>) {
const resolveFonts: Array<Promise<void>> = [];
fontNames.forEach((fontName) => {
if (!this.dependencies.fonts.has(fontName)) {
resolveFonts.push(
invoke("get_system_font", { fontName }).then((data) => {
if (Array.isArray(data)) {
const u8 = new Uint8Array(data as any);
const buffer = typedArrayToBuffer(u8);
this.dependencies.fonts.set(fontName, buffer);
}
})
);
}
});
await Promise.all(resolveFonts);
console.log(this.dependencies);
}
}

View File

@ -0,0 +1,100 @@
import { Drawer } from "drawers/draw";
import { AnimatedEntities } from "primitives/AnimatedEntities";
import { useEntitiesStore } from "stores/entities.store";
import { useRenderStateStore } from "stores/render-state.store";
import { useTimelineStore } from "stores/timeline.store";
export class PlaybackService {
drawer: Drawer;
lastDrawTime: number | undefined;
raf: number | undefined;
playing: boolean;
constructor() {
this.drawer = new Drawer();
this.lastDrawTime = undefined;
this.raf = undefined;
this.playing = false;
}
async init(canvas: HTMLCanvasElement) {
await this.drawer.init(canvas);
useRenderStateStore.subscribe((state) => {
if (!this.playing && state.playing) {
this.playing = true;
this.play();
}
if (this.playing && !state.playing) {
this.playing = false;
this.stop();
}
if (!this.playing && !state.playing) {
this.seek();
}
});
this.seek();
}
play() {
this.drawer.dependenciesService.prepareForAnimatedEntities(
this.animatedEntities
);
const currentTime = window.performance.now();
this.lastDrawTime = currentTime;
this.playLoop(currentTime);
}
stop() {
if (this.raf !== undefined) {
cancelAnimationFrame(this.raf);
}
}
seek() {
this.drawer.update(this.animatedEntities, true);
}
get animatedEntities() {
return AnimatedEntities.parse(useEntitiesStore.getState().entities);
}
get timelineStore() {
return useTimelineStore.getState();
}
get fpsInterval() {
return 1000 / this.timelineStore.fps;
}
get currFrame() {
return useRenderStateStore.getState().renderState.curr_frame;
}
get totalFrameCount() {
return this.timelineStore.fps * this.timelineStore.duration;
}
playLoop(currentTime: number) {
this.raf = requestAnimationFrame(this.playLoop.bind(this));
if (this.lastDrawTime !== undefined) {
const elapsed = currentTime - this.lastDrawTime;
if (elapsed > this.fpsInterval) {
this.lastDrawTime = currentTime - (elapsed % this.fpsInterval);
const nextFrame =
this.currFrame + 1 < this.totalFrameCount ? this.currFrame + 1 : 0;
useRenderStateStore.getState().setCurrentFrame(nextFrame);
this.drawer.update(this.animatedEntities, false);
}
}
}
}

View File

@ -9,6 +9,7 @@ interface EntitiesStore {
selectedEntity: number | undefined;
selectEntity: (index: number) => void;
deselectEntity: () => void;
setEntities: (entities: z.input<typeof AnimatedEntities>) => void;
updateEntity: (
index: number,
entity: Partial<z.input<typeof AnimatedEntity>>
@ -24,6 +25,7 @@ const useEntitiesStore = create<EntitiesStore>((set) => ({
selectEntity: (index) => set(() => ({ selectedEntity: index })),
deselectEntity: () => set(() => ({ selectedEntity: undefined })),
selectedEntity: undefined,
setEntities: (entities) => set({ entities }),
updateEntityById: (id, entity) =>
set(({ entities }) => {
const nextEntities = produce(entities, (draft) => {

View File

@ -4,6 +4,8 @@ import { create } from "zustand";
interface RenderStateStore {
renderState: z.infer<typeof RenderState>;
playing: boolean;
setPlaying: (playing: boolean) => void;
setCurrentFrame: (target: number) => void;
}
@ -11,6 +13,8 @@ const useRenderStateStore = create<RenderStateStore>((set) => ({
renderState: {
curr_frame: 20,
},
playing: false,
setPlaying: (playing) => set({ playing }),
setCurrentFrame: (target) =>
set((store) => {
store.renderState = {

View File

@ -7,8 +7,8 @@ interface TimelineStore {
}
const useTimelineStore = create<TimelineStore>((set) => ({
fps: 60,
size: [1920, 1080],
fps: 120,
size: [1280, 720],
duration: 10.0,
}));