add playback
This commit is contained in:
parent
60c8bb5877
commit
b671f9ee47
@ -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
|
||||
|
@ -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"
|
||||
>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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 } });
|
||||
|
0
app/src/drawers/paragraph.ts
Normal file
0
app/src/drawers/paragraph.ts
Normal 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();
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -300,7 +300,7 @@ const ExampleTimeline: z.input<typeof Timeline> = {
|
||||
render_state: {
|
||||
curr_frame: 20,
|
||||
},
|
||||
fps: 60,
|
||||
fps: 120,
|
||||
entities: EXAMPLE_ANIMATED_ENTITIES,
|
||||
};
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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),
|
||||
});
|
||||
|
||||
|
83
app/src/services/dependencies.service.ts
Normal file
83
app/src/services/dependencies.service.ts
Normal 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);
|
||||
}
|
||||
}
|
100
app/src/services/playback.service.ts
Normal file
100
app/src/services/playback.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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) => {
|
||||
|
@ -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 = {
|
||||
|
@ -7,8 +7,8 @@ interface TimelineStore {
|
||||
}
|
||||
|
||||
const useTimelineStore = create<TimelineStore>((set) => ({
|
||||
fps: 60,
|
||||
size: [1920, 1080],
|
||||
fps: 120,
|
||||
size: [1280, 720],
|
||||
duration: 10.0,
|
||||
}));
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user