add system font loading

improve drawing
implement staggered text
begin refactor of drawing code
This commit is contained in:
Enrico Bühler 2023-05-24 00:24:16 +02:00
parent 8523e44029
commit 330fa6a7f0
28 changed files with 844 additions and 207 deletions

View File

@ -1,3 +1,6 @@
{ {
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] "recommendations": [
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
} }

Binary file not shown.

View File

@ -24,6 +24,7 @@
"immer": "^10.0.2", "immer": "^10.0.2",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"uuid": "^9.0.0",
"zod": "^3.21.4", "zod": "^3.21.4",
"zustand": "^4.3.8" "zustand": "^4.3.8"
}, },
@ -32,6 +33,7 @@
"@types/node": "^18.7.10", "@types/node": "^18.7.10",
"@types/react": "^18.0.15", "@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",
"@types/uuid": "^9",
"@vitejs/plugin-react": "^3.0.0", "@vitejs/plugin-react": "^3.0.0",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"postcss": "^8.4.23", "postcss": "^8.4.23",

View File

@ -37,6 +37,11 @@ pub trait Animateable {
fn calculate(&mut self, timeline: &Timeline) -> Option<Entity>; fn calculate(&mut self, timeline: &Timeline) -> Option<Entity>;
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Cache {
pub valid: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum AnimatedEntity { pub enum AnimatedEntity {

View File

@ -9,11 +9,13 @@ use crate::animation::{
timeline::Timeline, timeline::Timeline,
}; };
use super::common::{Animateable, AnimationData, Drawable, Entity}; use super::common::{Animateable, AnimationData, Cache, Drawable, Entity};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnimatedEllipseEntity { pub struct AnimatedEllipseEntity {
pub paint: Paint, pub paint: Paint,
pub id: String,
pub cache: Cache,
pub radius: AnimatedFloatVec2, pub radius: AnimatedFloatVec2,
pub origin: AnimatedFloatVec2, pub origin: AnimatedFloatVec2,
pub position: AnimatedFloatVec2, pub position: AnimatedFloatVec2,
@ -24,6 +26,8 @@ pub struct AnimatedEllipseEntity {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EllipseEntity { pub struct EllipseEntity {
pub radius: (f32, f32), pub radius: (f32, f32),
pub cache: Cache,
pub id: String,
pub position: (f32, f32), pub position: (f32, f32),
pub origin: (f32, f32), pub origin: (f32, f32),
pub paint: Paint, pub paint: Paint,
@ -62,9 +66,11 @@ impl Animateable for AnimatedEllipseEntity {
}; };
Some(Entity::Ellipse(EllipseEntity { Some(Entity::Ellipse(EllipseEntity {
id: self.id.clone(),
radius, radius,
position, position,
origin, origin,
cache: self.cache.clone(),
paint: self.paint.clone(), paint: self.paint.clone(),
transform, transform,
})) }))

View File

@ -9,10 +9,12 @@ use crate::animation::{
timeline::Timeline, timeline::Timeline,
}; };
use super::common::{Animateable, AnimationData, Drawable, Entity}; use super::common::{Animateable, AnimationData, Cache, Drawable, Entity};
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnimatedRectEntity { pub struct AnimatedRectEntity {
pub id: String,
pub cache: Cache,
pub position: AnimatedFloatVec2, pub position: AnimatedFloatVec2,
pub size: AnimatedFloatVec2, pub size: AnimatedFloatVec2,
pub origin: AnimatedFloatVec2, pub origin: AnimatedFloatVec2,
@ -23,6 +25,8 @@ pub struct AnimatedRectEntity {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RectEntity { pub struct RectEntity {
pub id: String,
pub cache: Cache,
pub position: (f32, f32), pub position: (f32, f32),
pub size: (f32, f32), pub size: (f32, f32),
pub origin: (f32, f32), pub origin: (f32, f32),
@ -71,6 +75,8 @@ impl Animateable for AnimatedRectEntity {
}; };
Some(Entity::Rect(RectEntity { Some(Entity::Rect(RectEntity {
id: self.id.clone(),
cache: self.cache.clone(),
position, position,
size, size,
origin, origin,

View File

@ -1,8 +1,9 @@
use super::common::{Animateable, AnimationData, Drawable, Entity}; use super::common::{Animateable, AnimationData, Cache, Drawable, Entity};
use crate::animation::{ use crate::animation::{
primitives::{ primitives::{
paint::TextPaint, paint::TextPaint,
transform::{AnimatedTransform, Transform}, transform::{AnimatedTransform, Transform},
values::{AnimatedFloatVec2, AnimatedValue},
}, },
timeline::Timeline, timeline::Timeline,
}; };
@ -16,14 +17,17 @@ pub struct AnimatedStaggeredTextLetter {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StaggeredTextLetter { pub struct StaggeredTextLetter {
pub transform: Option<Transform>, pub transform: Option<Vec<Transform>>,
pub paint: TextPaint, pub paint: TextPaint,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnimatedStaggeredTextEntity { pub struct AnimatedStaggeredTextEntity {
pub id: String,
pub cache: Cache,
pub text: String, pub text: String,
pub stagger: f32, pub stagger: f32,
pub origin: AnimatedFloatVec2,
pub animation_data: AnimationData, pub animation_data: AnimationData,
pub transform: Option<AnimatedTransform>, pub transform: Option<AnimatedTransform>,
pub letter: AnimatedStaggeredTextLetter, pub letter: AnimatedStaggeredTextLetter,
@ -31,8 +35,11 @@ pub struct AnimatedStaggeredTextEntity {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StaggeredTextEntity { pub struct StaggeredTextEntity {
pub id: String,
pub cache: Cache,
pub text: String, pub text: String,
pub stagger: f32, pub stagger: f32,
pub origin: (f32, f32),
pub transform: Option<Transform>, pub transform: Option<Transform>,
pub animation_data: AnimationData, pub animation_data: AnimationData,
pub letter: StaggeredTextLetter, pub letter: StaggeredTextLetter,
@ -51,14 +58,36 @@ impl Animateable for AnimatedStaggeredTextEntity {
None => None, None => None,
}; };
let letter_transform: Option<Transform> = match self.letter.transform.clone() { // Iterate over the chars of the string and calculate the animation with the staggered offset
Some(mut val) => Some(val.calculate(timeline, &self.animation_data)), let letter_transform: Option<Vec<Transform>> = match self.letter.transform.clone() {
Some(mut val) => {
let mut transforms: Vec<Transform> = Vec::new();
for c in self.text.chars().enumerate() {
let mut animation_data = self.animation_data.clone();
animation_data.offset += self.stagger * c.0 as f32;
let transform = val.calculate(timeline, &animation_data);
transforms.push(transform);
}
Some(transforms)
}
None => None, None => None,
}; };
let origin = self.origin.get_value_at_frame(
timeline.render_state.curr_frame,
&self.animation_data,
timeline.fps,
);
Some(Entity::StaggeredText(StaggeredTextEntity { Some(Entity::StaggeredText(StaggeredTextEntity {
id: self.id.clone(),
transform, transform,
cache: self.cache.clone(),
stagger: self.stagger, stagger: self.stagger,
origin,
text: self.text.clone(), text: self.text.clone(),
animation_data: self.animation_data.clone(), animation_data: self.animation_data.clone(),
letter: StaggeredTextLetter { letter: StaggeredTextLetter {

View File

@ -8,10 +8,12 @@ use crate::animation::{
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::common::{Animateable, AnimationData, Drawable, Entity}; use super::common::{Animateable, AnimationData, Cache, Drawable, Entity};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TextEntity { pub struct TextEntity {
pub id: String,
pub cache: Cache,
pub text: String, pub text: String,
pub origin: (f32, f32), pub origin: (f32, f32),
pub paint: TextPaint, pub paint: TextPaint,
@ -20,6 +22,8 @@ pub struct TextEntity {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnimatedTextEntity { pub struct AnimatedTextEntity {
pub id: String,
pub cache: Cache,
pub text: String, pub text: String,
pub origin: AnimatedFloatVec2, pub origin: AnimatedFloatVec2,
pub paint: TextPaint, pub paint: TextPaint,
@ -45,6 +49,8 @@ impl AnimatedTextEntity {
}; };
TextEntity { TextEntity {
id: self.id.clone(),
cache: self.cache.clone(),
transform, transform,
text: self.text.clone(), text: self.text.clone(),
origin, origin,

View File

@ -1,3 +1,5 @@
use std::str::FromStr;
use crate::animation::primitives::{ use crate::animation::primitives::{
interpolations::{EasingFunction, InterpolationType, SpringProperties}, interpolations::{EasingFunction, InterpolationType, SpringProperties},
keyframe::{Keyframe, Keyframes}, keyframe::{Keyframe, Keyframes},
@ -7,7 +9,7 @@ use serde::{Deserialize, Serialize};
use super::primitives::{ use super::primitives::{
entities::{ entities::{
common::{AnimatedEntity, AnimationData, Entity}, common::{AnimatedEntity, AnimationData, Cache, Entity},
rect::AnimatedRectEntity, rect::AnimatedRectEntity,
text::AnimatedTextEntity, text::AnimatedTextEntity,
}, },
@ -52,12 +54,14 @@ impl Timeline {
fn build_bg(offset: f32, paint: Paint, size: (i32, i32)) -> AnimatedRectEntity { fn build_bg(offset: f32, paint: Paint, size: (i32, i32)) -> AnimatedRectEntity {
let bg_box = AnimatedRectEntity { let bg_box = AnimatedRectEntity {
id: String::from_str("1").unwrap(),
paint, paint,
animation_data: AnimationData { animation_data: AnimationData {
offset: 0.0 + offset, offset: 0.0 + offset,
duration: 5.0, duration: 5.0,
visible: true, visible: true,
}, },
cache: Cache { valid: false },
transform: None, transform: None,
origin: AnimatedFloatVec2::new(1280.0 / 2.0, 720.0 / 2.0), origin: AnimatedFloatVec2::new(1280.0 / 2.0, 720.0 / 2.0),
position: AnimatedFloatVec2 { position: AnimatedFloatVec2 {
@ -171,7 +175,9 @@ pub fn test_timeline_entities_at_frame(
AnimatedEntity::Rect(build_bg(0.5, rect2_paint, size)), AnimatedEntity::Rect(build_bg(0.5, rect2_paint, size)),
AnimatedEntity::Rect(build_bg(1.0, rect3_paint, size)), AnimatedEntity::Rect(build_bg(1.0, rect3_paint, size)),
AnimatedEntity::Text(AnimatedTextEntity { AnimatedEntity::Text(AnimatedTextEntity {
id: String::from_str("2").unwrap(),
paint: title_paint, paint: title_paint,
cache: Cache { valid: false },
text: input.title, text: input.title,
animation_data: AnimationData { animation_data: AnimationData {
offset: 0.0, offset: 0.0,
@ -216,8 +222,10 @@ pub fn test_timeline_entities_at_frame(
}, },
}), }),
AnimatedEntity::Text(AnimatedTextEntity { AnimatedEntity::Text(AnimatedTextEntity {
id: String::from_str("3").unwrap(),
paint: sub_title_paint, paint: sub_title_paint,
text: input.sub_title, text: input.sub_title,
cache: Cache { valid: false },
animation_data: AnimationData { animation_data: AnimationData {
offset: 0.5, offset: 0.5,
duration: 6.0, duration: 6.0,

View File

@ -12,3 +12,27 @@ pub fn get_system_fonts() -> Option<Vec<String>> {
found_families.ok() found_families.ok()
} }
#[tauri::command]
pub fn get_system_font(font_name: String) -> Option<Vec<u8>> {
let source = SystemSource::new();
let font = source.select_by_postscript_name(font_name.as_str());
match font {
Ok(font) => {
let font = font.load();
if let Ok(font) = font {
if let Some(font_data) = font.copy_font_data() {
Some(font_data.as_slice().to_owned())
} else {
None
}
} else {
None
}
}
Err(_) => None,
}
}

View File

@ -1,7 +1,10 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!! // Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use crate::{animation::timeline::calculate_timeline_entities_at_frame, fonts::get_system_fonts}; use crate::{
animation::timeline::calculate_timeline_entities_at_frame,
fonts::{get_system_font, get_system_fonts},
};
pub mod animation; pub mod animation;
pub mod fonts; pub mod fonts;
@ -10,6 +13,7 @@ fn main() {
tauri::Builder::default() tauri::Builder::default()
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
calculate_timeline_entities_at_frame, calculate_timeline_entities_at_frame,
get_system_font,
get_system_fonts get_system_fonts
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())

View File

@ -1,80 +0,0 @@
import { useEffect, useRef, useState } from "react";
import "./App.css";
import { invoke } from "@tauri-apps/api";
import { useTimelineStore } from "./stores/timeline.store";
import Timeline, { AnimationEntity } from "./components/Timeline";
import InitCanvasKit from "canvaskit-wasm";
import CanvasKitWasm from "canvaskit-wasm/bin/canvaskit.wasm?url";
const WIDTH = 1280 / 2;
const HEIGHT = 720 / 2;
const ENTITIES: Array<AnimationEntity> = [
{
offset: 0.2,
duration: 1.0,
},
];
function App() {
const canvas = useRef<HTMLCanvasElement>(null);
const [title, setTitle] = useState("Kleine");
const [subTitle, setSubTitle] = useState("Dumpfkopf");
const [loading, setLoading] = useState(true);
const { currentFrame } = useTimelineStore();
useEffect(() => {
console.time("render");
invoke("render_timeline_frame_cpu", {
currFrame: currentFrame,
title,
subTitle,
width: WIDTH,
height: HEIGHT,
}).then((data) => {
console.timeEnd("render");
if (canvas.current) {
const canvasElement = canvas.current;
canvasElement.width = WIDTH;
canvasElement.height = HEIGHT;
// console.time("draw");
const img = document.createElement("img");
const ctx = canvasElement.getContext("2d");
const arr = new Uint8ClampedArray(data as any);
const image = new Blob([arr], { type: "image/webp" });
img.src = URL.createObjectURL(image);
if (ctx) {
// ctx.fillStyle = "red";
// ctx.fillRect(0, 0, 1920, 1080);
}
img.onload = () => {
if (ctx) {
ctx.drawImage(img, 0, 0);
// console.timeEnd("draw");
}
};
}
});
}, [currentFrame, title, subTitle]);
if (loading) return;
return (
<div className="container">
<div style={{ width: "600px" }}>
<canvas style={{ width: "100%", height: "100%" }} ref={canvas}></canvas>
</div>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<input value={subTitle} onChange={(e) => setSubTitle(e.target.value)} />
<Timeline entities={ENTITIES} />
</div>
);
}

View File

@ -5,15 +5,27 @@ import { useTimelineStore } from "stores/timeline.store";
import InitCanvasKit, { CanvasKit } from "canvaskit-wasm"; import InitCanvasKit, { CanvasKit } from "canvaskit-wasm";
import { Surface } from "canvaskit-wasm"; import { Surface } from "canvaskit-wasm";
import drawText from "drawers/text"; import drawText from "drawers/text";
import drawBox from "drawers/box"; import drawRect from "drawers/rect";
import { Entities, EntityType } from "primitives/Entities"; import { Entities, EntityType } from "primitives/Entities";
import drawEllipse from "drawers/ellipse"; 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 { AnimatedEntities } from "primitives/AnimatedEntities";
import drawStaggeredText, {
StaggeredTextCache,
calculateLetters,
} from "drawers/staggered-text";
import useMap from "hooks/useMap";
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);
@ -21,15 +33,17 @@ const CanvasComponent: FC<CanvasProps> = () => {
const [canvasKit, setCanvasKit] = useState<CanvasKit>(); const [canvasKit, setCanvasKit] = useState<CanvasKit>();
const [fontData, setFontData] = useState<ArrayBuffer>(); const [fontData, setFontData] = useState<ArrayBuffer>();
const surface = useRef<Surface>(); const surface = useRef<Surface>();
const staggeredTextCache = useMap<string, StaggeredTextCache>();
const isLocked = useRef<boolean>(false);
const renderState = useRenderStateStore((store) => store.renderState); const renderState = useRenderStateStore((store) => store.renderState);
const { fps, size, duration } = useTimelineStore((store) => ({ const { fps, size, duration } = useTimelineStore((store) => ({
fps: store.fps, fps: store.fps,
size: store.size, size: store.size,
duration: store.duration, duration: store.duration,
})); }));
const { entities } = useEntitiesStore((store) => ({ const { entities, updateEntityById } = useEntitiesStore((store) => ({
entities: store.entities, entities: store.entities,
updateEntityById: store.updateEntityById,
})); }));
useEffect(() => { useEffect(() => {
@ -37,14 +51,23 @@ const CanvasComponent: FC<CanvasProps> = () => {
locateFile: (file) => locateFile: (file) =>
"https://unpkg.com/canvaskit-wasm@latest/bin/" + file, "https://unpkg.com/canvaskit-wasm@latest/bin/" + file,
}).then((CanvasKit) => { }).then((CanvasKit) => {
setLoading(false);
setCanvasKit(CanvasKit); setCanvasKit(CanvasKit);
fetch("https://storage.googleapis.com/skia-cdn/misc/Roboto-Regular.ttf") /* fetch("https://storage.googleapis.com/skia-cdn/misc/Roboto-Regular.ttf")
.then((response) => response.arrayBuffer()) .then((response) => response.arrayBuffer())
.then((arrayBuffer) => { .then((arrayBuffer) => {
setLoading(false);
setFontData(arrayBuffer); setFontData(arrayBuffer);
}); }); */
invoke("get_system_font", { fontName: "Helvetica-Bold" }).then((data) => {
console.log(data);
if (Array.isArray(data)) {
const u8 = new Uint8Array(data as any);
const buffer = typedArrayToBuffer(u8);
setFontData(buffer);
setLoading(false);
if (canvas.current) { if (canvas.current) {
const CSurface = CanvasKit.MakeWebGLCanvasSurface(canvas.current); const CSurface = CanvasKit.MakeWebGLCanvasSurface(canvas.current);
@ -52,13 +75,17 @@ const CanvasComponent: FC<CanvasProps> = () => {
surface.current = CSurface; surface.current = CSurface;
} }
} }
}
});
}); });
}, []); }, []);
useEffect(() => { useEffect(() => {
console.time("calculation"); //console.time("calculation");
const parsedEntities = AnimatedEntities.parse(entities); const parsedEntities = AnimatedEntities.parse(entities);
if (!loading && !isLocked.current) {
isLocked.current = true;
invoke("calculate_timeline_entities_at_frame", { invoke("calculate_timeline_entities_at_frame", {
timeline: { timeline: {
entities: parsedEntities, entities: parsedEntities,
@ -68,14 +95,11 @@ const CanvasComponent: FC<CanvasProps> = () => {
duration, duration,
}, },
}).then((data) => { }).then((data) => {
console.timeEnd("calculation");
// console.log(data);
const entitiesResult = Entities.safeParse(data); const entitiesResult = Entities.safeParse(data);
console.time("draw");
if (canvasKit && canvas.current && surface.current && fontData) { if (canvasKit && canvas.current && surface.current && fontData) {
surface.current.flush(); surface.current.flush();
surface.current.requestAnimationFrame((skCanvas) => { surface.current.requestAnimationFrame((skCanvas) => {
skCanvas.clear(canvasKit.WHITE); skCanvas.clear(canvasKit.WHITE);
if (entitiesResult.success) { if (entitiesResult.success) {
@ -83,27 +107,65 @@ const CanvasComponent: FC<CanvasProps> = () => {
entities.reverse().forEach((entity) => { entities.reverse().forEach((entity) => {
switch (entity.type) { switch (entity.type) {
case EntityType.Enum.Box: case EntityType.Enum.Rect:
drawBox(canvasKit, skCanvas, entity); drawRect(canvasKit, skCanvas, entity);
break; break;
case EntityType.Enum.Ellipse: case EntityType.Enum.Ellipse:
drawEllipse(canvasKit, skCanvas, entity); drawEllipse(canvasKit, skCanvas, entity);
break; break;
case EntityType.Enum.Text: case EntityType.Enum.Text:
drawText(canvasKit, skCanvas, entity, fontData); drawText(canvasKit, skCanvas, entity, fontData);
break;
case EntityType.Enum.StaggeredText:
{
let cache: StaggeredTextCache;
if (!entity.cache.valid) {
const _cache = staggeredTextCache[0].get(entity.id);
if (_cache !== undefined) {
canvasKit.Free(_cache.glyphs);
}
cache = calculateLetters(canvasKit, entity, fontData);
staggeredTextCache[1].set(entity.id, cache);
updateEntityById(entity.id, { cache: { valid: true } });
} else {
const _cache = staggeredTextCache[0].get(entity.id);
if (_cache) {
cache = _cache;
} else {
cache = calculateLetters(canvasKit, entity, fontData);
}
}
drawStaggeredText(
canvasKit,
skCanvas,
entity,
cache.font,
cache.letterMeasures,
cache.metrics
);
}
break; break;
default: default:
break; break;
} }
isLocked.current = false;
}); });
} else { } else {
isLocked.current = false;
console.log(entitiesResult.error); console.log(entitiesResult.error);
} }
}); });
} }
console.timeEnd("draw"); //console.timeEnd("draw");
});
}); });
}
}, [entities, loading, renderState.curr_frame]);
return ( return (
<div> <div>

View File

@ -2,18 +2,22 @@ import { ease } from "@unom/style";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { import {
AnimatedTextEntity, AnimatedTextEntity,
AnimatedBoxEntity, AnimatedRectEntity,
AnimatedStaggeredTextEntity,
AnimatedEllipseEntity, AnimatedEllipseEntity,
} from "primitives/AnimatedEntities"; } from "primitives/AnimatedEntities";
import { Paint, PaintStyle, PaintStyleType } from "primitives/Paint"; import { Paint, PaintStyle, PaintStyleType } from "primitives/Paint";
import { FC } from "react"; import { FC } from "react";
import { z } from "zod"; import { z } from "zod";
import { AnimatedVec2Properties } from "./Values"; import { AnimatedVec2Properties, ColorProperties } from "./Values";
import { PropertiesProps } from "./common"; import { PropertiesProps } from "./common";
type TextPropertiesProps = PropertiesProps<z.input<typeof AnimatedTextEntity>>; type TextPropertiesProps = PropertiesProps<z.input<typeof AnimatedTextEntity>>;
type StaggeredTextPropertiesProps = PropertiesProps<
z.input<typeof AnimatedStaggeredTextEntity>
>;
type PaintPropertiesProps = PropertiesProps<z.input<typeof Paint>>; type PaintPropertiesProps = PropertiesProps<z.input<typeof Paint>>;
type BoxPropertiesProps = PropertiesProps<z.input<typeof AnimatedBoxEntity>>; type RectPropertiesProps = PropertiesProps<z.input<typeof AnimatedRectEntity>>;
type EllipsePropertiesProps = PropertiesProps< type EllipsePropertiesProps = PropertiesProps<
z.input<typeof AnimatedEllipseEntity> z.input<typeof AnimatedEllipseEntity>
>; >;
@ -45,6 +49,15 @@ export const PaintProperties: FC<PaintPropertiesProps> = ({
))} ))}
</select> </select>
</label> </label>
{entity.style.color && (
<ColorProperties
label="Color"
onUpdate={(color) =>
onUpdate({ ...entity, style: { ...entity.style, color } })
}
entity={entity.style.color}
/>
)}
</div> </div>
); );
}; };
@ -90,7 +103,75 @@ export const TextProperties: FC<TextPropertiesProps> = ({
); );
}; };
export const BoxProperties: FC<BoxPropertiesProps> = ({ entity, onUpdate }) => { export const StaggeredTextProperties: FC<StaggeredTextPropertiesProps> = ({
entity,
onUpdate,
}) => {
return (
<motion.div
variants={{ enter: { opacity: 1, y: 0 }, from: { opacity: 0, y: 50 } }}
animate="enter"
initial="from"
transition={ease.quint(0.9).out}
>
<label className="flex flex-col items-start">
<span className="label">Text</span>
<input
value={entity.text}
onChange={(e) =>
onUpdate({
...entity,
text: e.target.value,
cache: { valid: false },
})
}
/>
</label>
<label className="flex flex-col items-start">
<span className="label">Size</span>
<input
value={entity.letter.paint.size}
onChange={(e) =>
onUpdate({
...entity,
letter: {
...entity.letter,
paint: {
...entity.letter.paint,
size: Number(e.target.value),
},
},
})
}
></input>
</label>
<PaintProperties
entity={entity.letter.paint}
onUpdate={(paint) =>
onUpdate({
...entity,
letter: {
...entity.letter,
paint: { ...entity.letter.paint, ...paint },
},
})
}
/>
<AnimatedVec2Properties
onUpdate={(updatedEntity) =>
onUpdate({ ...entity, origin: updatedEntity })
}
label="Origin"
entity={entity.origin}
/>
</motion.div>
);
};
export const RectProperties: FC<RectPropertiesProps> = ({
entity,
onUpdate,
}) => {
return ( return (
<div className="dark:text-white"> <div className="dark:text-white">
<PaintProperties <PaintProperties

View File

@ -2,7 +2,12 @@ import { FC, ReactNode } from "react";
import { useEntitiesStore } from "stores/entities.store"; import { useEntitiesStore } from "stores/entities.store";
import { shallow } from "zustand/shallow"; import { shallow } from "zustand/shallow";
import { BoxProperties, EllipseProperties, TextProperties } from "./Primitives"; import {
RectProperties,
EllipseProperties,
TextProperties,
StaggeredTextProperties,
} from "./Primitives";
const PropertiesContainer: FC<{ children: ReactNode }> = ({ children }) => { const PropertiesContainer: FC<{ children: ReactNode }> = ({ children }) => {
return ( return (
@ -26,6 +31,15 @@ const Properties = () => {
if (entity) { if (entity) {
switch (entity.type) { switch (entity.type) {
case "StaggeredText":
return (
<StaggeredTextProperties
key={selectedEntity}
onUpdate={(entity) => updateEntity(selectedEntity, entity)}
entity={entity}
/>
);
case "Text": case "Text":
return ( return (
<TextProperties <TextProperties
@ -35,9 +49,9 @@ const Properties = () => {
/> />
); );
case "Box": case "Rect":
return ( return (
<BoxProperties <RectProperties
key={selectedEntity} key={selectedEntity}
onUpdate={(entity) => updateEntity(selectedEntity, entity)} onUpdate={(entity) => updateEntity(selectedEntity, entity)}
entity={entity} entity={entity}

View File

@ -7,7 +7,7 @@ import { shallow } from "zustand/shallow";
import { useEntitiesStore } from "stores/entities.store"; import { useEntitiesStore } from "stores/entities.store";
import { ease } from "@unom/style"; import { ease } from "@unom/style";
import Timestamp from "./Timestamp"; import Timestamp from "./Timestamp";
import { Keyframe, Keyframes } from "primitives/Keyframe"; import { Keyframe } from "primitives/Keyframe";
import { flattenedKeyframesByEntity } from "utils"; import { flattenedKeyframesByEntity } from "utils";
export type AnimationEntity = { export type AnimationEntity = {
@ -37,8 +37,8 @@ const KeyframeIndicator: FC<{
style={{ style={{
clipPath: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)", clipPath: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)",
}} }}
className="bg-indigo-300 absolute w-2 h-2 z-30 top-[39%] select-none" className="bg-indigo-300 absolute w-2 h-2 z-30 top-[39%] select-none pointer-events-none"
></motion.div> />
); );
}; };
@ -55,7 +55,7 @@ const Track: FC<TrackProps> = ({ keyframes, animationData, index, name }) => {
); );
return ( return (
<div className="h-8 w-100 flex flex-row gap-1"> <div className="h-8 w-100 flex flex-row gap-1 select-none">
<div <div
onClick={() => onClick={() =>
selectedEntity !== undefined && selectedEntity === index selectedEntity !== undefined && selectedEntity === index
@ -90,6 +90,7 @@ const Track: FC<TrackProps> = ({ keyframes, animationData, index, name }) => {
whileTap={{ whileTap={{
scale: 0.9, scale: 0.9,
}} }}
onMouseDown={(e) => e.preventDefault()}
transition={ease.circ(0.6).out} transition={ease.circ(0.6).out}
dragElastic={false} dragElastic={false}
dragConstraints={{ left: 0, right: 900 }} dragConstraints={{ left: 0, right: 900 }}
@ -113,9 +114,10 @@ const Track: FC<TrackProps> = ({ keyframes, animationData, index, name }) => {
}, },
}); });
}} }}
className="z-10 w-4 bg-slate-500 h-full absolute rounded-md select-none" className="z-10 w-4 bg-slate-500 h-full absolute rounded-md select-none cursor-w-resize"
/> />
<motion.div <motion.div
onMouseDown={(e) => e.preventDefault()}
drag="x" drag="x"
animate={{ animate={{
x: (animationData.duration + animationData.offset) * 100 - 16, x: (animationData.duration + animationData.offset) * 100 - 16,
@ -142,7 +144,7 @@ const Track: FC<TrackProps> = ({ keyframes, animationData, index, name }) => {
}, },
}); });
}} }}
className="z-10 w-4 bg-slate-500 h-full absolute rounded-md select-none" className="z-10 w-4 bg-slate-500 h-full absolute rounded-md select-none cursor-e-resize"
/> />
<motion.div <motion.div
drag="x" drag="x"
@ -156,16 +158,12 @@ const Track: FC<TrackProps> = ({ keyframes, animationData, index, name }) => {
left: 0, left: 0,
right: 900, right: 900,
}} }}
onMouseDown={(e) => e.preventDefault()}
transition={ease.circ(0.8).out} transition={ease.circ(0.8).out}
onDragEnd={(e, info) => { onDragEnd={(_e, info) => {
let offset = info.offset.x; let offset = info.offset.x;
offset *= 0.01; offset *= 0.01;
offset += animationData.offset; offset += animationData.offset;
console.log(offset);
updateEntity(index, { updateEntity(index, {
animation_data: { animation_data: {
...animationData, ...animationData,
@ -173,7 +171,7 @@ 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" 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> </div>

76
app/src/drawers/draw.ts Normal file
View File

@ -0,0 +1,76 @@
import { invoke } from "@tauri-apps/api";
import InitCanvasKit, { CanvasKit } from "canvaskit-wasm";
import { AnimatedEntities } from "primitives/AnimatedEntities";
import { Entities } from "primitives/Entities";
import { useRenderStateStore } from "stores/render-state.store";
import { useTimelineStore } from "stores/timeline.store";
import { z } from "zod";
import { StaggeredTextCache } from "./staggered-text";
/**
*
* TODO Add dependency logic for e.g. dynamically loading fonts, images etc.
*/
export class Drawer {
private readonly didLoad: boolean;
private entities: z.output<typeof Entities> | undefined;
private ckDidLoad: boolean;
private dependenciesDidLoad: boolean;
private CanvasKit: CanvasKit | undefined;
cache: { staggeredText: Map<string, StaggeredTextCache> };
constructor() {
this.entities = undefined;
this.CanvasKit = undefined;
this.ckDidLoad = false;
this.dependenciesDidLoad = false;
this.cache = {
staggeredText: new Map(),
};
this.didLoad = this.ckDidLoad && this.dependenciesDidLoad;
}
init() {
InitCanvasKit({
locateFile: (file) =>
"https://unpkg.com/canvaskit-wasm@latest/bin/" + file,
}).then((CanvasKit) => {
this.CanvasKit = CanvasKit;
});
}
/**
* Updates the entities based on the input
*/
update(animatedEntities: z.input<typeof AnimatedEntities>) {
const parsedAnimatedEntities = AnimatedEntities.parse(animatedEntities);
if (this.didLoad) {
const render_state = useRenderStateStore.getState().renderState;
const { fps, size, duration } = useTimelineStore.getState();
invoke("calculate_timeline_entities_at_frame", {
timeline: {
entities: parsedAnimatedEntities,
render_state,
fps,
size,
duration,
},
}).then((data) => {
const parsedEntities = Entities.safeParse(data);
if (parsedEntities.success) {
this.entities = parsedEntities.data;
} else {
console.error(parsedEntities.error);
}
});
}
}
draw() {
if (this.didLoad) {
}
}
}

View File

@ -1,33 +1,278 @@
import { convertToFloat } from "@tempblade/common"; import {
import { Canvas, CanvasKit } from "canvaskit-wasm"; Canvas,
CanvasKit,
Font,
FontMetrics,
MallocObj,
TypedArray,
Typeface,
} from "canvaskit-wasm";
import { StaggeredText } from "primitives/Entities"; import { StaggeredText } from "primitives/Entities";
import { z } from "zod"; import { z } from "zod";
import { buildPaintStyle } from "./paint";
export default function drawStaggeredText( export type StaggeredTextCache = {
letterMeasures: Array<LetterMeasures>;
metrics: FontMetrics;
typeface: Typeface;
font: Font;
glyphs: MallocObj;
};
function getUniqueCharacters(str: string): string {
const uniqueCharacters: string[] = [];
for (let i = 0; i < str.length; i++) {
const character = str[i];
if (!uniqueCharacters.includes(character)) {
uniqueCharacters.push(character);
}
}
return uniqueCharacters.join("");
}
function measureLetters(
glyphArr: TypedArray,
boundsById: Record<number, LetterBounds>,
maxWidth: number
): Array<LetterMeasures> {
const measuredLetters: Array<LetterMeasures> = [];
let currentWidth = 0;
let currentLine = 0;
for (let i = 0; i < glyphArr.length; i++) {
const nextGlyph = boundsById[glyphArr[i]];
const nextGlyphWidth = nextGlyph.x_advance;
currentWidth += nextGlyphWidth;
if (currentWidth > maxWidth) {
currentLine += 1;
currentWidth = 0;
}
const glyph = glyphArr.subarray(i, i + 1) as unknown as MallocObj;
measuredLetters.push({
bounds: nextGlyph,
line: currentLine,
glyph,
offset: {
x: currentWidth - nextGlyphWidth,
},
});
}
return measuredLetters;
}
type LetterBounds = {
x: {
max: number;
min: number;
};
y: {
max: number;
min: number;
};
width: number;
height: number;
x_advance: number;
};
type LetterMeasures = {
offset: {
x: number;
};
line: number;
glyph: MallocObj;
bounds: LetterBounds;
};
export function calculateLetters(
CanvasKit: CanvasKit, CanvasKit: CanvasKit,
canvs: Canvas,
entity: z.output<typeof StaggeredText>, entity: z.output<typeof StaggeredText>,
fontData: ArrayBuffer fontData: ArrayBuffer
) { ): StaggeredTextCache {
const paint = new CanvasKit.Paint(); console.log("Called");
const color = convertToFloat(entity.letter.paint.style.color.value); const typeface = CanvasKit.Typeface.MakeFreeTypeFaceFromData(
fontData
paint.setColor(color); ) as Typeface;
const typeface = CanvasKit.Typeface.MakeFreeTypeFaceFromData(fontData);
const font = new CanvasKit.Font(typeface, entity.letter.paint.size); const font = new CanvasKit.Font(typeface, entity.letter.paint.size);
console.log(font.isDeleted());
const glyphIDs = font.getGlyphIDs(entity.text); const glyphIDs = font.getGlyphIDs(entity.text);
font.setLinearMetrics(true); font.setLinearMetrics(true);
font.setSubpixel(true); font.setSubpixel(true);
font.setHinting(CanvasKit.FontHinting.Slight); font.setHinting(CanvasKit.FontHinting.None);
const bounds = font.getGlyphBounds(glyphIDs, paint); const alphabet = getUniqueCharacters(entity.text);
const widths = font.getGlyphWidths(glyphIDs, paint); const ids = font.getGlyphIDs(alphabet);
const unknownCharacterGlyphID = ids[0];
console.log(bounds); const charsToGlyphIDs: Record<string, any> = {};
console.log(widths);
let glyphIdx = 0;
for (let i = 0; i < alphabet.length; i++) {
charsToGlyphIDs[alphabet[i]] = ids[glyphIdx];
if ((alphabet.codePointAt(i) as number) > 65535) {
i++; // skip the next index because that will be the second half of the code point.
}
glyphIdx++;
}
const metrics = font.getMetrics();
const bounds = font.getGlyphBounds(glyphIDs);
const widths = font.getGlyphWidths(glyphIDs);
const glyphMetricsByGlyphID: Record<number, LetterBounds> = {};
for (let i = 0; i < glyphIDs.length; i++) {
const id = glyphIDs[i];
const x_min = bounds[i * 4];
const x_max = bounds[i * 4 + 2];
const y_min = bounds[i * 4 + 3];
const y_max = bounds[i * 4 + 1];
const width = x_max - x_min;
const height = Math.abs(y_max - y_min);
glyphMetricsByGlyphID[id] = {
x: {
min: x_min,
max: x_max,
},
y: {
min: y_min,
max: y_max,
},
width,
height,
x_advance: widths[i],
};
}
const glyphs = CanvasKit.MallocGlyphIDs(entity.text.length);
let glyphArr = glyphs.toTypedArray();
const MAX_WIDTH = 900;
// Turn the code points into glyphs, accounting for up to 2 ligatures.
let shapedGlyphIdx = -1;
for (let i = 0; i < entity.text.length; i++) {
const char = entity.text[i];
shapedGlyphIdx++;
glyphArr[shapedGlyphIdx] = charsToGlyphIDs[char] || unknownCharacterGlyphID;
if ((entity.text.codePointAt(i) as number) > 65535) {
i++; // skip the next index because that will be the second half of the code point.
}
}
// Trim down our array of glyphs to only the amount we have after ligatures and code points
// that are > 16 bits.
glyphArr = glyphs.subarray(0, shapedGlyphIdx + 1);
// Break our glyphs into runs based on the maxWidth and the xAdvance.
const letterMeasures = measureLetters(
glyphArr,
glyphMetricsByGlyphID,
MAX_WIDTH
);
return { letterMeasures, metrics, font, typeface, glyphs };
}
export default function drawStaggeredText(
CanvasKit: CanvasKit,
canvas: Canvas,
entity: z.output<typeof StaggeredText>,
font: Font,
measuredLetters: Array<LetterMeasures>,
metrics: FontMetrics
) {
const paint = new CanvasKit.Paint();
buildPaintStyle(CanvasKit, paint, entity.letter.paint);
// Draw all those runs.
for (let i = 0; i < measuredLetters.length; i++) {
const measuredLetter = measuredLetters[i];
const blob = CanvasKit.TextBlob.MakeFromGlyphs(
measuredLetters[i].glyph as unknown as Array<number>,
font
);
if (blob) {
canvas.save();
const width = measuredLetters
.filter((letter) => letter.line === 0)
.reduce((prev, curr) => curr.bounds.x_advance + prev, 0);
const lineOffset = (entity.letter.paint.size / 2) * measuredLetter.line;
const entityOrigin = [
entity.origin[0] - width / 2,
entity.origin[1] + lineOffset,
];
const lineCount = measuredLetters
.map((e) => e.line)
.sort((a, b) => a - b)[measuredLetters.length - 1];
if (entity.letter.transform && entity.letter.transform[i]) {
const letterTransform = entity.letter.transform[i];
const letterOrigin = [0, 0];
let origin = letterOrigin.map(
(val, index) => val + entityOrigin[index]
);
// Calculate the spacing
const spacing =
measuredLetter.bounds.x_advance - measuredLetter.bounds.width;
//console.log(spacing);
// Center the origin
origin[0] =
origin[0] + measuredLetter.bounds.width / 2 + measuredLetter.offset.x;
origin[1] = origin[1] - metrics.descent + lineOffset;
//console.log(measuredLetter.bounds);
canvas.translate(origin[0], origin[1]);
canvas.scale(letterTransform.scale[0], letterTransform.scale[1]);
canvas.translate(
-origin[0] + measuredLetter.offset.x,
-origin[1] + lineOffset
);
/* canvas.translate(
measuredLetter.offset.x + measuredLetter.bounds.width / 2,
0
); */
}
/* canvas.translate(
width * -0.5,
lineCount * (-entity.letter.paint.size / 2)
); */
canvas.drawTextBlob(blob, entityOrigin[0], entityOrigin[1], paint);
canvas.restore();
}
}
} }

View File

@ -9,6 +9,7 @@ export default function drawText(
entity: z.output<typeof TextEntity>, entity: z.output<typeof TextEntity>,
fontData: ArrayBuffer fontData: ArrayBuffer
) { ) {
canvas.save();
const fontMgr = CanvasKit.FontMgr.FromData(fontData); const fontMgr = CanvasKit.FontMgr.FromData(fontData);
if (!fontMgr) { if (!fontMgr) {
@ -25,7 +26,7 @@ export default function drawText(
const pStyle = new CanvasKit.ParagraphStyle({ const pStyle = new CanvasKit.ParagraphStyle({
textStyle: { textStyle: {
color: color, color: color,
fontFamilies: ["Roboto"], fontFamilies: ["Helvetica"],
fontSize: entity.paint.size, fontSize: entity.paint.size,
}, },
textDirection: CanvasKit.TextDirection.LTR, textDirection: CanvasKit.TextDirection.LTR,
@ -40,4 +41,8 @@ export default function drawText(
const width = p.getMaxWidth() / 2; const width = p.getMaxWidth() / 2;
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();
} }

View File

@ -3,12 +3,15 @@ import { Color } from "primitives/Paint";
import { Timeline } from "primitives/Timeline"; import { Timeline } from "primitives/Timeline";
import { staticAnimatedNumber, staticAnimatedVec2 } from "primitives/Values"; import { staticAnimatedNumber, staticAnimatedVec2 } from "primitives/Values";
import { z } from "zod"; import { z } from "zod";
import { v4 as uuid } from "uuid";
function buildRect1( function buildRect1(
offset: number, offset: number,
color: z.infer<typeof Color> color: z.infer<typeof Color>
): z.input<typeof AnimatedEntity> { ): z.input<typeof AnimatedEntity> {
return { return {
id: uuid(),
cache: {},
type: "Rect", type: "Rect",
paint: { paint: {
style: { style: {
@ -58,6 +61,8 @@ function buildRect(
): z.input<typeof AnimatedEntity> { ): z.input<typeof AnimatedEntity> {
return { return {
type: "Rect", type: "Rect",
id: uuid(),
cache: {},
paint: { paint: {
style: { style: {
type: "Fill", type: "Fill",
@ -126,6 +131,8 @@ function buildText(
): z.input<typeof AnimatedEntity> { ): z.input<typeof AnimatedEntity> {
return { return {
type: "Text", type: "Text",
id: uuid(),
cache: {},
paint: { paint: {
style: { style: {
type: "Fill", type: "Fill",
@ -179,6 +186,9 @@ function buildStaggeredText(
return { return {
type: "StaggeredText", type: "StaggeredText",
text, text,
cache: {},
id: uuid(),
origin: staticAnimatedVec2(1280 / 2, 720 / 2),
transform: { transform: {
translate: staticAnimatedVec2(0, 0), translate: staticAnimatedVec2(0, 0),
rotate: staticAnimatedVec2(0, 0), rotate: staticAnimatedVec2(0, 0),
@ -186,17 +196,17 @@ function buildStaggeredText(
scale: staticAnimatedVec2(1, 1), scale: staticAnimatedVec2(1, 1),
}, },
animation_data: { animation_data: {
offset: 0, offset,
duration: 2, duration: 5.0,
}, },
stagger: 2.0, stagger: 0.05,
letter: { letter: {
paint: { paint: {
style: { style: {
type: "Fill", type: "Fill",
color, color,
}, },
size: 30, size: 90,
align: "Center", align: "Center",
}, },
transform: { transform: {
@ -208,12 +218,22 @@ function buildStaggeredText(
{ {
keyframes: { keyframes: {
values: [ values: [
{
interpolation: {
type: "Spring",
stiffness: 200,
mass: 1,
damping: 15,
},
value: 0.0,
offset: 0.0,
},
{ {
interpolation: { interpolation: {
type: "Linear", type: "Linear",
}, },
value: 1.0, value: 1.0,
offset: 0.0, offset: 4.0,
}, },
], ],
}, },
@ -223,8 +243,10 @@ function buildStaggeredText(
values: [ values: [
{ {
interpolation: { interpolation: {
type: "EasingFunction", type: "Spring",
easing_function: "CircOut", stiffness: 200,
mass: 1,
damping: 15,
}, },
value: 0.0, value: 0.0,
offset: 0.0, offset: 0.0,
@ -248,7 +270,13 @@ function buildStaggeredText(
export const EXAMPLE_ANIMATED_ENTITIES: Array<z.input<typeof AnimatedEntity>> = export const EXAMPLE_ANIMATED_ENTITIES: Array<z.input<typeof AnimatedEntity>> =
[ [
buildStaggeredText("Hallo", 0.0, { value: [30, 30, 30, 1.0] }), buildStaggeredText("Ehrenmann?", 2.0, {
value: [255, 255, 255, 1.0],
}),
buildText("Wie gehts?", 2.5, 40, 40, { value: [200, 200, 200, 1.0] }),
buildRect(0.6, { value: [30, 30, 30, 1.0] }),
buildRect(0.4, { value: [20, 20, 20, 1.0] }),
buildRect(0.2, { value: [10, 10, 10, 1.0] }),
buildRect(0, { value: [0, 0, 0, 1.0] }), buildRect(0, { value: [0, 0, 0, 1.0] }),
]; ];
@ -259,6 +287,7 @@ export const EXAMPLE_ANIMATED_ENTITIES_2: Array<
value: [255, 255, 255, 1.0], value: [255, 255, 255, 1.0],
}), }),
buildText("Wie gehts?", 1.5, 40, 30, { value: [255, 255, 255, 1.0] }), buildText("Wie gehts?", 1.5, 40, 30, { value: [255, 255, 255, 1.0] }),
buildRect(0.8, { value: [40, 40, 40, 1.0] }),
buildRect(0.6, { value: [30, 30, 30, 1.0] }), buildRect(0.6, { value: [30, 30, 30, 1.0] }),
buildRect(0.4, { value: [20, 20, 20, 1.0] }), buildRect(0.4, { value: [20, 20, 20, 1.0] }),
buildRect(0.2, { value: [10, 10, 10, 1.0] }), buildRect(0.2, { value: [10, 10, 10, 1.0] }),

53
app/src/hooks/useMap.ts Normal file
View File

@ -0,0 +1,53 @@
import { useCallback, useState } from "react";
export type MapOrEntries<K, V> = Map<K, V> | [K, V][];
// Public interface
export interface Actions<K, V> {
set: (key: K, value: V) => void;
setAll: (entries: MapOrEntries<K, V>) => void;
remove: (key: K) => void;
reset: Map<K, V>["clear"];
}
// We hide some setters from the returned map to disable autocompletion
type Return<K, V> = [
Omit<Map<K, V>, "set" | "clear" | "delete">,
Actions<K, V>
];
function useMap<K, V>(
initialState: MapOrEntries<K, V> = new Map()
): Return<K, V> {
const [map, setMap] = useState(new Map(initialState));
const actions: Actions<K, V> = {
set: useCallback((key, value) => {
setMap((prev) => {
const copy = new Map(prev);
copy.set(key, value);
return copy;
});
}, []),
setAll: useCallback((entries) => {
setMap(() => new Map(entries));
}, []),
remove: useCallback((key) => {
setMap((prev) => {
const copy = new Map(prev);
copy.delete(key);
return copy;
});
}, []),
reset: useCallback(() => {
setMap(() => new Map());
}, []),
};
return [map, actions];
}
export default useMap;

View File

@ -1,5 +1,11 @@
import { z } from "zod"; import { z } from "zod";
import { EllipseEntity, EntityType, RectEntity, TextEntity } from "./Entities"; import {
BaseEntity,
EllipseEntity,
EntityType,
RectEntity,
TextEntity,
} from "./Entities";
import { AnimatedVec2 } from "./Values"; import { AnimatedVec2 } from "./Values";
import { TextPaint } from "./Paint"; import { TextPaint } from "./Paint";
@ -20,7 +26,7 @@ export const AnimatedTransform = z.object({
scale: AnimatedVec2, scale: AnimatedVec2,
}); });
export const AnimatedStaggeredText = z.object({ export const AnimatedStaggeredTextEntity = BaseEntity.extend({
/** Transform applied to the whole layer. */ /** Transform applied to the whole layer. */
transform: AnimatedTransform, transform: AnimatedTransform,
/** The staggered delay that is applied for each letter. Gets multiplied by the index of the letter. */ /** The staggered delay that is applied for each letter. Gets multiplied by the index of the letter. */
@ -31,6 +37,7 @@ export const AnimatedStaggeredText = z.object({
paint: TextPaint, paint: TextPaint,
}), }),
text: z.string(), text: z.string(),
origin: AnimatedVec2,
animation_data: AnimationData, animation_data: AnimationData,
type: z.literal(EntityType.Enum.StaggeredText), type: z.literal(EntityType.Enum.StaggeredText),
}); });
@ -60,7 +67,7 @@ export const AnimatedEllipseEntity = EllipseEntity.extend({
export const AnimatedEntity = z.discriminatedUnion("type", [ export const AnimatedEntity = z.discriminatedUnion("type", [
AnimatedRectEntity, AnimatedRectEntity,
AnimatedTextEntity, AnimatedTextEntity,
AnimatedStaggeredText, AnimatedStaggeredTextEntity,
AnimatedEllipseEntity, AnimatedEllipseEntity,
]); ]);

View File

@ -6,10 +6,6 @@ const EntityTypeOptions = ["Text", "Ellipse", "Rect", "StaggeredText"] as const;
export const EntityType = z.enum(EntityTypeOptions); export const EntityType = z.enum(EntityTypeOptions);
export const GeometryEntity = z.object({
paint: Paint,
});
export const Transform = z.object({ export const Transform = z.object({
skew: Vec2, skew: Vec2,
rotate: Vec2, rotate: Vec2,
@ -17,12 +13,25 @@ export const Transform = z.object({
scale: Vec2, scale: Vec2,
}); });
export const StaggeredText = z.object({ export const Cache = z.object({
valid: z.boolean().optional().default(false),
});
export const BaseEntity = z.object({
id: z.string(),
cache: Cache,
});
export const GeometryEntity = BaseEntity.extend({
paint: Paint,
});
export const StaggeredText = BaseEntity.extend({
letter: z.object({ letter: z.object({
position: Vec2, transform: z.array(Transform).optional(),
transform: Transform,
paint: TextPaint, paint: TextPaint,
}), }),
origin: Vec2,
text: z.string(), text: z.string(),
type: z.literal(EntityType.Enum.StaggeredText), type: z.literal(EntityType.Enum.StaggeredText),
}); });
@ -43,7 +52,7 @@ export const EllipseEntity = GeometryEntity.extend({
transform: z.nullable(Transform), transform: z.nullable(Transform),
}); });
export const TextEntity = z.object({ export const TextEntity = BaseEntity.extend({
type: z.literal(EntityType.Enum.Text), type: z.literal(EntityType.Enum.Text),
paint: TextPaint, paint: TextPaint,
origin: Vec2, origin: Vec2,
@ -55,6 +64,7 @@ export const Entity = z.discriminatedUnion("type", [
RectEntity, RectEntity,
EllipseEntity, EllipseEntity,
TextEntity, TextEntity,
StaggeredText,
]); ]);
export const Entities = z.array(Entity); export const Entities = z.array(Entity);

View File

@ -13,6 +13,10 @@ interface EntitiesStore {
index: number, index: number,
entity: Partial<z.input<typeof AnimatedEntity>> entity: Partial<z.input<typeof AnimatedEntity>>
) => void; ) => void;
updateEntityById: (
id: string,
entity: Partial<z.input<typeof AnimatedEntity>>
) => void;
} }
const useEntitiesStore = create<EntitiesStore>((set) => ({ const useEntitiesStore = create<EntitiesStore>((set) => ({
@ -20,6 +24,18 @@ 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,
updateEntityById: (id, entity) =>
set(({ entities }) => {
const nextEntities = produce(entities, (draft) => {
const index = draft.findIndex((e) => e.id === id);
draft[index] = { ...draft[index], ...entity } as z.infer<
typeof AnimatedEntity
>;
});
return { entities: nextEntities };
}),
updateEntity: (index, entity) => updateEntity: (index, entity) =>
set(({ entities }) => { set(({ entities }) => {
const nextEntities = produce(entities, (draft) => { const nextEntities = produce(entities, (draft) => {

View File

@ -105,10 +105,6 @@ body,
transition: opacity 0.1s linear, filter 0.1s linear; transition: opacity 0.1s linear, filter 0.1s linear;
} }
.SliderThumb:hover {
filter: drop-shadow(0px 10px 10px white);
}
.SliderThumb::before { .SliderThumb::before {
content: ""; content: "";
background-color: var(--indigo-400); background-color: var(--indigo-400);

View File

@ -29,7 +29,7 @@ export function flattenedKeyframesByEntity(
case "Text": case "Text":
keyframes.push(...flattenAnimatedVec2Keyframes(entity.origin)); keyframes.push(...flattenAnimatedVec2Keyframes(entity.origin));
break; break;
case "Box": case "Rect":
keyframes.push(...flattenAnimatedVec2Keyframes(entity.position)); keyframes.push(...flattenAnimatedVec2Keyframes(entity.position));
keyframes.push(...flattenAnimatedVec2Keyframes(entity.size)); keyframes.push(...flattenAnimatedVec2Keyframes(entity.size));
break; break;
@ -37,6 +37,21 @@ export function flattenedKeyframesByEntity(
keyframes.push(...flattenAnimatedVec2Keyframes(entity.position)); keyframes.push(...flattenAnimatedVec2Keyframes(entity.position));
keyframes.push(...flattenAnimatedVec2Keyframes(entity.radius)); keyframes.push(...flattenAnimatedVec2Keyframes(entity.radius));
break; break;
case "StaggeredText":
keyframes.push(
...flattenAnimatedVec2Keyframes(entity.letter.transform.rotate)
);
keyframes.push(
...flattenAnimatedVec2Keyframes(entity.letter.transform.translate)
);
keyframes.push(
...flattenAnimatedVec2Keyframes(entity.letter.transform.skew)
);
keyframes.push(
...flattenAnimatedVec2Keyframes(entity.letter.transform.scale)
);
keyframes.push(...flattenAnimatedVec2Keyframes(entity.origin));
break;
default: default:
break; break;
} }

View File

@ -16,7 +16,6 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Node", "moduleResolution": "Node",
"resolvePackageJsonExports": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,

View File

@ -1266,6 +1266,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/uuid@npm:^9":
version: 9.0.1
resolution: "@types/uuid@npm:9.0.1"
checksum: ca96225b4d47b8146380c0f60921b2a0b62acf3a8def03e8e7b369ed3b0180989c12f8305eb71ffc0cecb944102e18eec1260a3e84040d9a48de987d3aa00148
languageName: node
linkType: hard
"@unom/style@npm:^0.2.14": "@unom/style@npm:^0.2.14":
version: 0.2.14 version: 0.2.14
resolution: "@unom/style@npm:0.2.14" resolution: "@unom/style@npm:0.2.14"
@ -1379,6 +1386,7 @@ __metadata:
"@types/node": "npm:^18.7.10" "@types/node": "npm:^18.7.10"
"@types/react": "npm:^18.0.15" "@types/react": "npm:^18.0.15"
"@types/react-dom": "npm:^18.0.6" "@types/react-dom": "npm:^18.0.6"
"@types/uuid": "npm:^9"
"@unom/style": "npm:^0.2.14" "@unom/style": "npm:^0.2.14"
"@vitejs/plugin-react": "npm:^3.0.0" "@vitejs/plugin-react": "npm:^3.0.0"
autoprefixer: "npm:^10.4.14" autoprefixer: "npm:^10.4.14"
@ -1390,6 +1398,7 @@ __metadata:
react-dom: "npm:^18.2.0" react-dom: "npm:^18.2.0"
tailwindcss: "npm:^3.3.2" tailwindcss: "npm:^3.3.2"
typescript: "npm:^4.9.5" typescript: "npm:^4.9.5"
uuid: "npm:^9.0.0"
vite: "npm:^4.2.1" vite: "npm:^4.2.1"
vite-tsconfig-paths: "npm:^4.2.0" vite-tsconfig-paths: "npm:^4.2.0"
zod: "npm:^3.21.4" zod: "npm:^3.21.4"
@ -3378,6 +3387,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"uuid@npm:^9.0.0":
version: 9.0.0
resolution: "uuid@npm:9.0.0"
bin:
uuid: dist/bin/uuid
checksum: e1f76aff372e430bb129157360fd4cd3b393411b245604ea78e178a822ab7874ec2ebecec03ce94422b0b80bbd24733c6fa1df166c9cbad5086ef4b4843140bb
languageName: node
linkType: hard
"vite-tsconfig-paths@npm:^4.2.0": "vite-tsconfig-paths@npm:^4.2.0":
version: 4.2.0 version: 4.2.0
resolution: "vite-tsconfig-paths@npm:4.2.0" resolution: "vite-tsconfig-paths@npm:4.2.0"