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 { FC, useMemo } from "react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api";
import { useTimelineStore } from "stores/timeline.store"; 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 { useRenderStateStore } from "stores/render-state.store";
import { useEntitiesStore } from "stores/entities.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 { Drawer } from "drawers/draw";
import { PlaybackService } from "services/playback.service";
type CanvasProps = {}; type CanvasProps = {};
function typedArrayToBuffer(array: Uint8Array): ArrayBuffer {
return array.buffer.slice(
array.byteOffset,
array.byteLength + array.byteOffset
);
}
const CanvasComponent: FC<CanvasProps> = () => { const CanvasComponent: FC<CanvasProps> = () => {
const canvas = useRef<HTMLCanvasElement>(null); const canvas = useRef<HTMLCanvasElement>(null);
const [didInit, setDidInit] = useState(false); 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(() => { useEffect(() => {
if (canvas.current && !didInit) { if (canvas.current && !didInit) {
drawer playbackService
.init(canvas.current) .init(canvas.current)
.then(() => { .then(() => {
setDidInit(true); setDidInit(true);
@ -54,12 +25,6 @@ const CanvasComponent: FC<CanvasProps> = () => {
} }
}, []); }, []);
useEffect(() => {
if (didInit) {
drawer.update(entities);
}
}, [entities, renderState.curr_frame, didInit]);
return ( return (
<div> <div>
<div <div

View File

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

View File

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

View File

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

View File

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

View File

View File

@ -7,10 +7,11 @@ import {
TypedArray, TypedArray,
Typeface, Typeface,
} from "canvaskit-wasm"; } from "canvaskit-wasm";
import { StaggeredText } from "primitives/Entities"; import { StaggeredTextEntity } from "primitives/Entities";
import { z } from "zod"; import { z } from "zod";
import { buildPaintStyle } from "./paint"; import { buildPaintStyle } from "./paint";
import { EntityCache } from "./cache"; import { EntityCache } from "./cache";
import { Dependencies } from "services/dependencies.service";
export type StaggeredTextCache = { export type StaggeredTextCache = {
letterMeasures: Array<LetterMeasures>; letterMeasures: Array<LetterMeasures>;
@ -94,9 +95,13 @@ type LetterMeasures = {
export function calculateLetters( export function calculateLetters(
CanvasKit: CanvasKit, CanvasKit: CanvasKit,
entity: z.output<typeof StaggeredText>, entity: z.output<typeof StaggeredTextEntity>,
fontData: ArrayBuffer dependencies: Dependencies
): StaggeredTextCache { ): StaggeredTextCache {
const fontData = dependencies.fonts.get(
entity.letter.paint.fontName
) as ArrayBuffer;
const typeface = CanvasKit.Typeface.MakeFreeTypeFaceFromData( const typeface = CanvasKit.Typeface.MakeFreeTypeFaceFromData(
fontData fontData
) as Typeface; ) as Typeface;
@ -189,7 +194,7 @@ export function calculateLetters(
export default function drawStaggeredText( export default function drawStaggeredText(
CanvasKit: CanvasKit, CanvasKit: CanvasKit,
canvas: Canvas, canvas: Canvas,
entity: z.output<typeof StaggeredText>, entity: z.output<typeof StaggeredTextEntity>,
cache: StaggeredTextCache cache: StaggeredTextCache
) { ) {
const paint = new CanvasKit.Paint(); 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 { TextEntity } from "primitives/Entities";
import { convertToFloat } from "@tempblade/common"; import { convertToFloat } from "@tempblade/common";
import { z } from "zod"; import { z } from "zod";
import { EntityCache } from "./cache"; import { EntityCache } from "./cache";
import { Dependencies } from "services/dependencies.service";
import { buildPaintStyle } from "./paint";
export type TextCache = { export type TextCache = {
font: Font; fontManager: FontMgr;
}; };
export type TextEntityCache = EntityCache<TextCache>; 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( export default function drawText(
CanvasKit: CanvasKit, CanvasKit: CanvasKit,
canvas: Canvas, canvas: Canvas,
entity: z.output<typeof TextEntity>, entity: z.output<typeof TextEntity>,
fontData: ArrayBuffer cache: TextCache
) { ) {
canvas.save(); canvas.save();
const fontMgr = CanvasKit.FontMgr.FromData(fontData);
if (!fontMgr) {
console.error("No FontMgr");
return;
}
const paint = new CanvasKit.Paint(); const paint = new CanvasKit.Paint();
const color = convertToFloat(entity.paint.style.color.value); const color = convertToFloat(entity.paint.style.color.value);
paint.setColor(color); buildPaintStyle(CanvasKit, paint, entity.paint);
const pStyle = new CanvasKit.ParagraphStyle({ const pStyle = new CanvasKit.ParagraphStyle({
textStyle: { textStyle: {
color: color, color: color,
fontFamilies: ["Helvetica"], fontFamilies: [entity.paint.fontName],
fontSize: entity.paint.size, fontSize: entity.paint.size,
}, },
textDirection: CanvasKit.TextDirection.LTR, textDirection: CanvasKit.TextDirection.LTR,
textAlign: CanvasKit.TextAlign[entity.paint.align], 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); builder.addText(entity.text);
const p = builder.build(); const p = builder.build();
p.layout(900); p.layout(900);
@ -49,7 +59,7 @@ export default function drawText(
canvas.drawParagraph(p, entity.origin[0] - width, entity.origin[1] - height); canvas.drawParagraph(p, entity.origin[0] - width, entity.origin[1] - height);
paint.delete();
canvas.restore(); canvas.restore();
builder.delete();
} }

View File

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

View File

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

View File

@ -35,6 +35,7 @@ export const Paint = z.object({
export const TextPaint = z.object({ export const TextPaint = z.object({
style: PaintStyle, style: PaintStyle,
align: TextAlign, align: TextAlign,
fontName: z.string().default("Helvetica-Bold"),
size: z.number().min(0), 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; selectedEntity: number | undefined;
selectEntity: (index: number) => void; selectEntity: (index: number) => void;
deselectEntity: () => void; deselectEntity: () => void;
setEntities: (entities: z.input<typeof AnimatedEntities>) => void;
updateEntity: ( updateEntity: (
index: number, index: number,
entity: Partial<z.input<typeof AnimatedEntity>> entity: Partial<z.input<typeof AnimatedEntity>>
@ -24,6 +25,7 @@ const useEntitiesStore = create<EntitiesStore>((set) => ({
selectEntity: (index) => set(() => ({ selectedEntity: index })), selectEntity: (index) => set(() => ({ selectedEntity: index })),
deselectEntity: () => set(() => ({ selectedEntity: undefined })), deselectEntity: () => set(() => ({ selectedEntity: undefined })),
selectedEntity: undefined, selectedEntity: undefined,
setEntities: (entities) => set({ entities }),
updateEntityById: (id, entity) => updateEntityById: (id, entity) =>
set(({ entities }) => { set(({ entities }) => {
const nextEntities = produce(entities, (draft) => { const nextEntities = produce(entities, (draft) => {

View File

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

View File

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