add system font loading
improve drawing implement staggered text begin refactor of drawing code
This commit is contained in:
parent
8523e44029
commit
330fa6a7f0
5
app/.vscode/extensions.json
vendored
5
app/.vscode/extensions.json
vendored
@ -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.
@ -24,6 +24,7 @@
|
||||
"immer": "^10.0.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"uuid": "^9.0.0",
|
||||
"zod": "^3.21.4",
|
||||
"zustand": "^4.3.8"
|
||||
},
|
||||
@ -32,6 +33,7 @@
|
||||
"@types/node": "^18.7.10",
|
||||
"@types/react": "^18.0.15",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@types/uuid": "^9",
|
||||
"@vitejs/plugin-react": "^3.0.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.23",
|
||||
|
@ -37,6 +37,11 @@ pub trait Animateable {
|
||||
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)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum AnimatedEntity {
|
||||
|
@ -9,11 +9,13 @@ use crate::animation::{
|
||||
timeline::Timeline,
|
||||
};
|
||||
|
||||
use super::common::{Animateable, AnimationData, Drawable, Entity};
|
||||
use super::common::{Animateable, AnimationData, Cache, Drawable, Entity};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnimatedEllipseEntity {
|
||||
pub paint: Paint,
|
||||
pub id: String,
|
||||
pub cache: Cache,
|
||||
pub radius: AnimatedFloatVec2,
|
||||
pub origin: AnimatedFloatVec2,
|
||||
pub position: AnimatedFloatVec2,
|
||||
@ -24,6 +26,8 @@ pub struct AnimatedEllipseEntity {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EllipseEntity {
|
||||
pub radius: (f32, f32),
|
||||
pub cache: Cache,
|
||||
pub id: String,
|
||||
pub position: (f32, f32),
|
||||
pub origin: (f32, f32),
|
||||
pub paint: Paint,
|
||||
@ -62,9 +66,11 @@ impl Animateable for AnimatedEllipseEntity {
|
||||
};
|
||||
|
||||
Some(Entity::Ellipse(EllipseEntity {
|
||||
id: self.id.clone(),
|
||||
radius,
|
||||
position,
|
||||
origin,
|
||||
cache: self.cache.clone(),
|
||||
paint: self.paint.clone(),
|
||||
transform,
|
||||
}))
|
||||
|
@ -9,10 +9,12 @@ use crate::animation::{
|
||||
timeline::Timeline,
|
||||
};
|
||||
|
||||
use super::common::{Animateable, AnimationData, Drawable, Entity};
|
||||
use super::common::{Animateable, AnimationData, Cache, Drawable, Entity};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnimatedRectEntity {
|
||||
pub id: String,
|
||||
pub cache: Cache,
|
||||
pub position: AnimatedFloatVec2,
|
||||
pub size: AnimatedFloatVec2,
|
||||
pub origin: AnimatedFloatVec2,
|
||||
@ -23,6 +25,8 @@ pub struct AnimatedRectEntity {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RectEntity {
|
||||
pub id: String,
|
||||
pub cache: Cache,
|
||||
pub position: (f32, f32),
|
||||
pub size: (f32, f32),
|
||||
pub origin: (f32, f32),
|
||||
@ -71,6 +75,8 @@ impl Animateable for AnimatedRectEntity {
|
||||
};
|
||||
|
||||
Some(Entity::Rect(RectEntity {
|
||||
id: self.id.clone(),
|
||||
cache: self.cache.clone(),
|
||||
position,
|
||||
size,
|
||||
origin,
|
||||
|
@ -1,8 +1,9 @@
|
||||
use super::common::{Animateable, AnimationData, Drawable, Entity};
|
||||
use super::common::{Animateable, AnimationData, Cache, Drawable, Entity};
|
||||
use crate::animation::{
|
||||
primitives::{
|
||||
paint::TextPaint,
|
||||
transform::{AnimatedTransform, Transform},
|
||||
values::{AnimatedFloatVec2, AnimatedValue},
|
||||
},
|
||||
timeline::Timeline,
|
||||
};
|
||||
@ -16,14 +17,17 @@ pub struct AnimatedStaggeredTextLetter {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StaggeredTextLetter {
|
||||
pub transform: Option<Transform>,
|
||||
pub transform: Option<Vec<Transform>>,
|
||||
pub paint: TextPaint,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnimatedStaggeredTextEntity {
|
||||
pub id: String,
|
||||
pub cache: Cache,
|
||||
pub text: String,
|
||||
pub stagger: f32,
|
||||
pub origin: AnimatedFloatVec2,
|
||||
pub animation_data: AnimationData,
|
||||
pub transform: Option<AnimatedTransform>,
|
||||
pub letter: AnimatedStaggeredTextLetter,
|
||||
@ -31,8 +35,11 @@ pub struct AnimatedStaggeredTextEntity {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StaggeredTextEntity {
|
||||
pub id: String,
|
||||
pub cache: Cache,
|
||||
pub text: String,
|
||||
pub stagger: f32,
|
||||
pub origin: (f32, f32),
|
||||
pub transform: Option<Transform>,
|
||||
pub animation_data: AnimationData,
|
||||
pub letter: StaggeredTextLetter,
|
||||
@ -51,14 +58,36 @@ impl Animateable for AnimatedStaggeredTextEntity {
|
||||
None => None,
|
||||
};
|
||||
|
||||
let letter_transform: Option<Transform> = match self.letter.transform.clone() {
|
||||
Some(mut val) => Some(val.calculate(timeline, &self.animation_data)),
|
||||
// Iterate over the chars of the string and calculate the animation with the staggered offset
|
||||
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,
|
||||
};
|
||||
|
||||
let origin = self.origin.get_value_at_frame(
|
||||
timeline.render_state.curr_frame,
|
||||
&self.animation_data,
|
||||
timeline.fps,
|
||||
);
|
||||
|
||||
Some(Entity::StaggeredText(StaggeredTextEntity {
|
||||
id: self.id.clone(),
|
||||
transform,
|
||||
cache: self.cache.clone(),
|
||||
stagger: self.stagger,
|
||||
origin,
|
||||
text: self.text.clone(),
|
||||
animation_data: self.animation_data.clone(),
|
||||
letter: StaggeredTextLetter {
|
||||
|
@ -8,10 +8,12 @@ use crate::animation::{
|
||||
};
|
||||
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)]
|
||||
pub struct TextEntity {
|
||||
pub id: String,
|
||||
pub cache: Cache,
|
||||
pub text: String,
|
||||
pub origin: (f32, f32),
|
||||
pub paint: TextPaint,
|
||||
@ -20,6 +22,8 @@ pub struct TextEntity {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnimatedTextEntity {
|
||||
pub id: String,
|
||||
pub cache: Cache,
|
||||
pub text: String,
|
||||
pub origin: AnimatedFloatVec2,
|
||||
pub paint: TextPaint,
|
||||
@ -45,6 +49,8 @@ impl AnimatedTextEntity {
|
||||
};
|
||||
|
||||
TextEntity {
|
||||
id: self.id.clone(),
|
||||
cache: self.cache.clone(),
|
||||
transform,
|
||||
text: self.text.clone(),
|
||||
origin,
|
||||
|
@ -1,3 +1,5 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::animation::primitives::{
|
||||
interpolations::{EasingFunction, InterpolationType, SpringProperties},
|
||||
keyframe::{Keyframe, Keyframes},
|
||||
@ -7,7 +9,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::primitives::{
|
||||
entities::{
|
||||
common::{AnimatedEntity, AnimationData, Entity},
|
||||
common::{AnimatedEntity, AnimationData, Cache, Entity},
|
||||
rect::AnimatedRectEntity,
|
||||
text::AnimatedTextEntity,
|
||||
},
|
||||
@ -52,12 +54,14 @@ impl Timeline {
|
||||
|
||||
fn build_bg(offset: f32, paint: Paint, size: (i32, i32)) -> AnimatedRectEntity {
|
||||
let bg_box = AnimatedRectEntity {
|
||||
id: String::from_str("1").unwrap(),
|
||||
paint,
|
||||
animation_data: AnimationData {
|
||||
offset: 0.0 + offset,
|
||||
duration: 5.0,
|
||||
visible: true,
|
||||
},
|
||||
cache: Cache { valid: false },
|
||||
transform: None,
|
||||
origin: AnimatedFloatVec2::new(1280.0 / 2.0, 720.0 / 2.0),
|
||||
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(1.0, rect3_paint, size)),
|
||||
AnimatedEntity::Text(AnimatedTextEntity {
|
||||
id: String::from_str("2").unwrap(),
|
||||
paint: title_paint,
|
||||
cache: Cache { valid: false },
|
||||
text: input.title,
|
||||
animation_data: AnimationData {
|
||||
offset: 0.0,
|
||||
@ -216,8 +222,10 @@ pub fn test_timeline_entities_at_frame(
|
||||
},
|
||||
}),
|
||||
AnimatedEntity::Text(AnimatedTextEntity {
|
||||
id: String::from_str("3").unwrap(),
|
||||
paint: sub_title_paint,
|
||||
text: input.sub_title,
|
||||
cache: Cache { valid: false },
|
||||
animation_data: AnimationData {
|
||||
offset: 0.5,
|
||||
duration: 6.0,
|
||||
|
@ -12,3 +12,27 @@ pub fn get_system_fonts() -> Option<Vec<String>> {
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,10 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![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 fonts;
|
||||
@ -10,6 +13,7 @@ fn main() {
|
||||
tauri::Builder::default()
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
calculate_timeline_entities_at_frame,
|
||||
get_system_font,
|
||||
get_system_fonts
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -5,15 +5,27 @@ import { useTimelineStore } from "stores/timeline.store";
|
||||
import InitCanvasKit, { CanvasKit } from "canvaskit-wasm";
|
||||
import { Surface } from "canvaskit-wasm";
|
||||
import drawText from "drawers/text";
|
||||
import drawBox from "drawers/box";
|
||||
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";
|
||||
|
||||
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);
|
||||
|
||||
@ -21,15 +33,17 @@ const CanvasComponent: FC<CanvasProps> = () => {
|
||||
const [canvasKit, setCanvasKit] = useState<CanvasKit>();
|
||||
const [fontData, setFontData] = useState<ArrayBuffer>();
|
||||
const surface = useRef<Surface>();
|
||||
|
||||
const staggeredTextCache = useMap<string, StaggeredTextCache>();
|
||||
const isLocked = useRef<boolean>(false);
|
||||
const renderState = useRenderStateStore((store) => store.renderState);
|
||||
const { fps, size, duration } = useTimelineStore((store) => ({
|
||||
fps: store.fps,
|
||||
size: store.size,
|
||||
duration: store.duration,
|
||||
}));
|
||||
const { entities } = useEntitiesStore((store) => ({
|
||||
const { entities, updateEntityById } = useEntitiesStore((store) => ({
|
||||
entities: store.entities,
|
||||
updateEntityById: store.updateEntityById,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
@ -37,73 +51,121 @@ const CanvasComponent: FC<CanvasProps> = () => {
|
||||
locateFile: (file) =>
|
||||
"https://unpkg.com/canvaskit-wasm@latest/bin/" + file,
|
||||
}).then((CanvasKit) => {
|
||||
setLoading(false);
|
||||
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((arrayBuffer) => {
|
||||
setLoading(false);
|
||||
setFontData(arrayBuffer);
|
||||
});
|
||||
}); */
|
||||
|
||||
if (canvas.current) {
|
||||
const CSurface = CanvasKit.MakeWebGLCanvasSurface(canvas.current);
|
||||
if (CSurface) {
|
||||
surface.current = CSurface;
|
||||
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) {
|
||||
const CSurface = CanvasKit.MakeWebGLCanvasSurface(canvas.current);
|
||||
if (CSurface) {
|
||||
surface.current = CSurface;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
console.time("calculation");
|
||||
//console.time("calculation");
|
||||
const parsedEntities = AnimatedEntities.parse(entities);
|
||||
|
||||
invoke("calculate_timeline_entities_at_frame", {
|
||||
timeline: {
|
||||
entities: parsedEntities,
|
||||
render_state: renderState,
|
||||
fps,
|
||||
size,
|
||||
duration,
|
||||
},
|
||||
}).then((data) => {
|
||||
console.timeEnd("calculation");
|
||||
// console.log(data);
|
||||
if (!loading && !isLocked.current) {
|
||||
isLocked.current = true;
|
||||
invoke("calculate_timeline_entities_at_frame", {
|
||||
timeline: {
|
||||
entities: parsedEntities,
|
||||
render_state: renderState,
|
||||
fps,
|
||||
size,
|
||||
duration,
|
||||
},
|
||||
}).then((data) => {
|
||||
const entitiesResult = Entities.safeParse(data);
|
||||
|
||||
const entitiesResult = Entities.safeParse(data);
|
||||
console.time("draw");
|
||||
if (canvasKit && canvas.current && surface.current && fontData) {
|
||||
surface.current.flush();
|
||||
|
||||
if (canvasKit && canvas.current && surface.current && fontData) {
|
||||
surface.current.flush();
|
||||
surface.current.requestAnimationFrame((skCanvas) => {
|
||||
skCanvas.clear(canvasKit.WHITE);
|
||||
if (entitiesResult.success) {
|
||||
const entities = entitiesResult.data;
|
||||
surface.current.requestAnimationFrame((skCanvas) => {
|
||||
skCanvas.clear(canvasKit.WHITE);
|
||||
if (entitiesResult.success) {
|
||||
const entities = entitiesResult.data;
|
||||
|
||||
entities.reverse().forEach((entity) => {
|
||||
switch (entity.type) {
|
||||
case EntityType.Enum.Box:
|
||||
drawBox(canvasKit, skCanvas, entity);
|
||||
break;
|
||||
case EntityType.Enum.Ellipse:
|
||||
drawEllipse(canvasKit, skCanvas, entity);
|
||||
break;
|
||||
case EntityType.Enum.Text:
|
||||
drawText(canvasKit, skCanvas, entity, fontData);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log(entitiesResult.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
console.timeEnd("draw");
|
||||
});
|
||||
});
|
||||
entities.reverse().forEach((entity) => {
|
||||
switch (entity.type) {
|
||||
case EntityType.Enum.Rect:
|
||||
drawRect(canvasKit, skCanvas, entity);
|
||||
break;
|
||||
case EntityType.Enum.Ellipse:
|
||||
drawEllipse(canvasKit, skCanvas, entity);
|
||||
break;
|
||||
case EntityType.Enum.Text:
|
||||
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;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
isLocked.current = false;
|
||||
});
|
||||
} else {
|
||||
isLocked.current = false;
|
||||
console.log(entitiesResult.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
//console.timeEnd("draw");
|
||||
});
|
||||
}
|
||||
}, [entities, loading, renderState.curr_frame]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@ -2,18 +2,22 @@ import { ease } from "@unom/style";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
AnimatedTextEntity,
|
||||
AnimatedBoxEntity,
|
||||
AnimatedRectEntity,
|
||||
AnimatedStaggeredTextEntity,
|
||||
AnimatedEllipseEntity,
|
||||
} from "primitives/AnimatedEntities";
|
||||
import { Paint, PaintStyle, PaintStyleType } from "primitives/Paint";
|
||||
import { FC } from "react";
|
||||
import { z } from "zod";
|
||||
import { AnimatedVec2Properties } from "./Values";
|
||||
import { AnimatedVec2Properties, ColorProperties } from "./Values";
|
||||
import { PropertiesProps } from "./common";
|
||||
|
||||
type TextPropertiesProps = PropertiesProps<z.input<typeof AnimatedTextEntity>>;
|
||||
type StaggeredTextPropertiesProps = PropertiesProps<
|
||||
z.input<typeof AnimatedStaggeredTextEntity>
|
||||
>;
|
||||
type PaintPropertiesProps = PropertiesProps<z.input<typeof Paint>>;
|
||||
type BoxPropertiesProps = PropertiesProps<z.input<typeof AnimatedBoxEntity>>;
|
||||
type RectPropertiesProps = PropertiesProps<z.input<typeof AnimatedRectEntity>>;
|
||||
type EllipsePropertiesProps = PropertiesProps<
|
||||
z.input<typeof AnimatedEllipseEntity>
|
||||
>;
|
||||
@ -45,6 +49,15 @@ export const PaintProperties: FC<PaintPropertiesProps> = ({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{entity.style.color && (
|
||||
<ColorProperties
|
||||
label="Color"
|
||||
onUpdate={(color) =>
|
||||
onUpdate({ ...entity, style: { ...entity.style, color } })
|
||||
}
|
||||
entity={entity.style.color}
|
||||
/>
|
||||
)}
|
||||
</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 (
|
||||
<div className="dark:text-white">
|
||||
<PaintProperties
|
||||
|
@ -2,7 +2,12 @@ import { FC, ReactNode } from "react";
|
||||
import { useEntitiesStore } from "stores/entities.store";
|
||||
|
||||
import { shallow } from "zustand/shallow";
|
||||
import { BoxProperties, EllipseProperties, TextProperties } from "./Primitives";
|
||||
import {
|
||||
RectProperties,
|
||||
EllipseProperties,
|
||||
TextProperties,
|
||||
StaggeredTextProperties,
|
||||
} from "./Primitives";
|
||||
|
||||
const PropertiesContainer: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
@ -26,6 +31,15 @@ const Properties = () => {
|
||||
|
||||
if (entity) {
|
||||
switch (entity.type) {
|
||||
case "StaggeredText":
|
||||
return (
|
||||
<StaggeredTextProperties
|
||||
key={selectedEntity}
|
||||
onUpdate={(entity) => updateEntity(selectedEntity, entity)}
|
||||
entity={entity}
|
||||
/>
|
||||
);
|
||||
|
||||
case "Text":
|
||||
return (
|
||||
<TextProperties
|
||||
@ -35,9 +49,9 @@ const Properties = () => {
|
||||
/>
|
||||
);
|
||||
|
||||
case "Box":
|
||||
case "Rect":
|
||||
return (
|
||||
<BoxProperties
|
||||
<RectProperties
|
||||
key={selectedEntity}
|
||||
onUpdate={(entity) => updateEntity(selectedEntity, entity)}
|
||||
entity={entity}
|
||||
|
@ -7,7 +7,7 @@ import { shallow } from "zustand/shallow";
|
||||
import { useEntitiesStore } from "stores/entities.store";
|
||||
import { ease } from "@unom/style";
|
||||
import Timestamp from "./Timestamp";
|
||||
import { Keyframe, Keyframes } from "primitives/Keyframe";
|
||||
import { Keyframe } from "primitives/Keyframe";
|
||||
import { flattenedKeyframesByEntity } from "utils";
|
||||
|
||||
export type AnimationEntity = {
|
||||
@ -37,8 +37,8 @@ const KeyframeIndicator: FC<{
|
||||
style={{
|
||||
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"
|
||||
></motion.div>
|
||||
className="bg-indigo-300 absolute w-2 h-2 z-30 top-[39%] select-none pointer-events-none"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -55,7 +55,7 @@ const Track: FC<TrackProps> = ({ keyframes, animationData, index, name }) => {
|
||||
);
|
||||
|
||||
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
|
||||
onClick={() =>
|
||||
selectedEntity !== undefined && selectedEntity === index
|
||||
@ -90,6 +90,7 @@ const Track: FC<TrackProps> = ({ keyframes, animationData, index, name }) => {
|
||||
whileTap={{
|
||||
scale: 0.9,
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
transition={ease.circ(0.6).out}
|
||||
dragElastic={false}
|
||||
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
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
drag="x"
|
||||
animate={{
|
||||
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
|
||||
drag="x"
|
||||
@ -156,16 +158,12 @@ const Track: FC<TrackProps> = ({ keyframes, animationData, index, name }) => {
|
||||
left: 0,
|
||||
right: 900,
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
transition={ease.circ(0.8).out}
|
||||
onDragEnd={(e, info) => {
|
||||
onDragEnd={(_e, info) => {
|
||||
let offset = info.offset.x;
|
||||
|
||||
offset *= 0.01;
|
||||
|
||||
offset += animationData.offset;
|
||||
|
||||
console.log(offset);
|
||||
|
||||
updateEntity(index, {
|
||||
animation_data: {
|
||||
...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>
|
||||
</div>
|
||||
</div>
|
||||
|
76
app/src/drawers/draw.ts
Normal file
76
app/src/drawers/draw.ts
Normal 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) {
|
||||
}
|
||||
}
|
||||
}
|
@ -1,33 +1,278 @@
|
||||
import { convertToFloat } from "@tempblade/common";
|
||||
import { Canvas, CanvasKit } from "canvaskit-wasm";
|
||||
import {
|
||||
Canvas,
|
||||
CanvasKit,
|
||||
Font,
|
||||
FontMetrics,
|
||||
MallocObj,
|
||||
TypedArray,
|
||||
Typeface,
|
||||
} from "canvaskit-wasm";
|
||||
import { StaggeredText } from "primitives/Entities";
|
||||
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,
|
||||
canvs: Canvas,
|
||||
entity: z.output<typeof StaggeredText>,
|
||||
fontData: ArrayBuffer
|
||||
) {
|
||||
const paint = new CanvasKit.Paint();
|
||||
): StaggeredTextCache {
|
||||
console.log("Called");
|
||||
|
||||
const color = convertToFloat(entity.letter.paint.style.color.value);
|
||||
|
||||
paint.setColor(color);
|
||||
|
||||
const typeface = CanvasKit.Typeface.MakeFreeTypeFaceFromData(fontData);
|
||||
const typeface = CanvasKit.Typeface.MakeFreeTypeFaceFromData(
|
||||
fontData
|
||||
) as Typeface;
|
||||
|
||||
const font = new CanvasKit.Font(typeface, entity.letter.paint.size);
|
||||
|
||||
console.log(font.isDeleted());
|
||||
|
||||
const glyphIDs = font.getGlyphIDs(entity.text);
|
||||
|
||||
font.setLinearMetrics(true);
|
||||
font.setSubpixel(true);
|
||||
font.setHinting(CanvasKit.FontHinting.Slight);
|
||||
font.setHinting(CanvasKit.FontHinting.None);
|
||||
|
||||
const bounds = font.getGlyphBounds(glyphIDs, paint);
|
||||
const widths = font.getGlyphWidths(glyphIDs, paint);
|
||||
const alphabet = getUniqueCharacters(entity.text);
|
||||
const ids = font.getGlyphIDs(alphabet);
|
||||
const unknownCharacterGlyphID = ids[0];
|
||||
|
||||
console.log(bounds);
|
||||
console.log(widths);
|
||||
const charsToGlyphIDs: Record<string, any> = {};
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ export default function drawText(
|
||||
entity: z.output<typeof TextEntity>,
|
||||
fontData: ArrayBuffer
|
||||
) {
|
||||
canvas.save();
|
||||
const fontMgr = CanvasKit.FontMgr.FromData(fontData);
|
||||
|
||||
if (!fontMgr) {
|
||||
@ -25,7 +26,7 @@ export default function drawText(
|
||||
const pStyle = new CanvasKit.ParagraphStyle({
|
||||
textStyle: {
|
||||
color: color,
|
||||
fontFamilies: ["Roboto"],
|
||||
fontFamilies: ["Helvetica"],
|
||||
fontSize: entity.paint.size,
|
||||
},
|
||||
textDirection: CanvasKit.TextDirection.LTR,
|
||||
@ -40,4 +41,8 @@ export default function drawText(
|
||||
const width = p.getMaxWidth() / 2;
|
||||
|
||||
canvas.drawParagraph(p, entity.origin[0] - width, entity.origin[1] - height);
|
||||
|
||||
paint.delete();
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
|
@ -3,12 +3,15 @@ import { Color } from "primitives/Paint";
|
||||
import { Timeline } from "primitives/Timeline";
|
||||
import { staticAnimatedNumber, staticAnimatedVec2 } from "primitives/Values";
|
||||
import { z } from "zod";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
function buildRect1(
|
||||
offset: number,
|
||||
color: z.infer<typeof Color>
|
||||
): z.input<typeof AnimatedEntity> {
|
||||
return {
|
||||
id: uuid(),
|
||||
cache: {},
|
||||
type: "Rect",
|
||||
paint: {
|
||||
style: {
|
||||
@ -58,6 +61,8 @@ function buildRect(
|
||||
): z.input<typeof AnimatedEntity> {
|
||||
return {
|
||||
type: "Rect",
|
||||
id: uuid(),
|
||||
cache: {},
|
||||
paint: {
|
||||
style: {
|
||||
type: "Fill",
|
||||
@ -126,6 +131,8 @@ function buildText(
|
||||
): z.input<typeof AnimatedEntity> {
|
||||
return {
|
||||
type: "Text",
|
||||
id: uuid(),
|
||||
cache: {},
|
||||
paint: {
|
||||
style: {
|
||||
type: "Fill",
|
||||
@ -179,6 +186,9 @@ function buildStaggeredText(
|
||||
return {
|
||||
type: "StaggeredText",
|
||||
text,
|
||||
cache: {},
|
||||
id: uuid(),
|
||||
origin: staticAnimatedVec2(1280 / 2, 720 / 2),
|
||||
transform: {
|
||||
translate: staticAnimatedVec2(0, 0),
|
||||
rotate: staticAnimatedVec2(0, 0),
|
||||
@ -186,17 +196,17 @@ function buildStaggeredText(
|
||||
scale: staticAnimatedVec2(1, 1),
|
||||
},
|
||||
animation_data: {
|
||||
offset: 0,
|
||||
duration: 2,
|
||||
offset,
|
||||
duration: 5.0,
|
||||
},
|
||||
stagger: 2.0,
|
||||
stagger: 0.05,
|
||||
letter: {
|
||||
paint: {
|
||||
style: {
|
||||
type: "Fill",
|
||||
color,
|
||||
},
|
||||
size: 30,
|
||||
size: 90,
|
||||
align: "Center",
|
||||
},
|
||||
transform: {
|
||||
@ -208,12 +218,22 @@ function buildStaggeredText(
|
||||
{
|
||||
keyframes: {
|
||||
values: [
|
||||
{
|
||||
interpolation: {
|
||||
type: "Spring",
|
||||
stiffness: 200,
|
||||
mass: 1,
|
||||
damping: 15,
|
||||
},
|
||||
value: 0.0,
|
||||
offset: 0.0,
|
||||
},
|
||||
{
|
||||
interpolation: {
|
||||
type: "Linear",
|
||||
},
|
||||
value: 1.0,
|
||||
offset: 0.0,
|
||||
offset: 4.0,
|
||||
},
|
||||
],
|
||||
},
|
||||
@ -223,8 +243,10 @@ function buildStaggeredText(
|
||||
values: [
|
||||
{
|
||||
interpolation: {
|
||||
type: "EasingFunction",
|
||||
easing_function: "CircOut",
|
||||
type: "Spring",
|
||||
stiffness: 200,
|
||||
mass: 1,
|
||||
damping: 15,
|
||||
},
|
||||
value: 0.0,
|
||||
offset: 0.0,
|
||||
@ -248,7 +270,13 @@ function buildStaggeredText(
|
||||
|
||||
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] }),
|
||||
];
|
||||
|
||||
@ -259,6 +287,7 @@ export const EXAMPLE_ANIMATED_ENTITIES_2: Array<
|
||||
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.4, { value: [20, 20, 20, 1.0] }),
|
||||
buildRect(0.2, { value: [10, 10, 10, 1.0] }),
|
||||
|
53
app/src/hooks/useMap.ts
Normal file
53
app/src/hooks/useMap.ts
Normal 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;
|
@ -1,5 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import { EllipseEntity, EntityType, RectEntity, TextEntity } from "./Entities";
|
||||
import {
|
||||
BaseEntity,
|
||||
EllipseEntity,
|
||||
EntityType,
|
||||
RectEntity,
|
||||
TextEntity,
|
||||
} from "./Entities";
|
||||
import { AnimatedVec2 } from "./Values";
|
||||
import { TextPaint } from "./Paint";
|
||||
|
||||
@ -20,7 +26,7 @@ export const AnimatedTransform = z.object({
|
||||
scale: AnimatedVec2,
|
||||
});
|
||||
|
||||
export const AnimatedStaggeredText = z.object({
|
||||
export const AnimatedStaggeredTextEntity = BaseEntity.extend({
|
||||
/** Transform applied to the whole layer. */
|
||||
transform: AnimatedTransform,
|
||||
/** 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,
|
||||
}),
|
||||
text: z.string(),
|
||||
origin: AnimatedVec2,
|
||||
animation_data: AnimationData,
|
||||
type: z.literal(EntityType.Enum.StaggeredText),
|
||||
});
|
||||
@ -60,7 +67,7 @@ export const AnimatedEllipseEntity = EllipseEntity.extend({
|
||||
export const AnimatedEntity = z.discriminatedUnion("type", [
|
||||
AnimatedRectEntity,
|
||||
AnimatedTextEntity,
|
||||
AnimatedStaggeredText,
|
||||
AnimatedStaggeredTextEntity,
|
||||
AnimatedEllipseEntity,
|
||||
]);
|
||||
|
||||
|
@ -6,10 +6,6 @@ const EntityTypeOptions = ["Text", "Ellipse", "Rect", "StaggeredText"] as const;
|
||||
|
||||
export const EntityType = z.enum(EntityTypeOptions);
|
||||
|
||||
export const GeometryEntity = z.object({
|
||||
paint: Paint,
|
||||
});
|
||||
|
||||
export const Transform = z.object({
|
||||
skew: Vec2,
|
||||
rotate: Vec2,
|
||||
@ -17,12 +13,25 @@ export const Transform = z.object({
|
||||
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({
|
||||
position: Vec2,
|
||||
transform: Transform,
|
||||
transform: z.array(Transform).optional(),
|
||||
paint: TextPaint,
|
||||
}),
|
||||
origin: Vec2,
|
||||
text: z.string(),
|
||||
type: z.literal(EntityType.Enum.StaggeredText),
|
||||
});
|
||||
@ -43,7 +52,7 @@ export const EllipseEntity = GeometryEntity.extend({
|
||||
transform: z.nullable(Transform),
|
||||
});
|
||||
|
||||
export const TextEntity = z.object({
|
||||
export const TextEntity = BaseEntity.extend({
|
||||
type: z.literal(EntityType.Enum.Text),
|
||||
paint: TextPaint,
|
||||
origin: Vec2,
|
||||
@ -55,6 +64,7 @@ export const Entity = z.discriminatedUnion("type", [
|
||||
RectEntity,
|
||||
EllipseEntity,
|
||||
TextEntity,
|
||||
StaggeredText,
|
||||
]);
|
||||
|
||||
export const Entities = z.array(Entity);
|
||||
|
@ -13,6 +13,10 @@ interface EntitiesStore {
|
||||
index: number,
|
||||
entity: Partial<z.input<typeof AnimatedEntity>>
|
||||
) => void;
|
||||
updateEntityById: (
|
||||
id: string,
|
||||
entity: Partial<z.input<typeof AnimatedEntity>>
|
||||
) => void;
|
||||
}
|
||||
|
||||
const useEntitiesStore = create<EntitiesStore>((set) => ({
|
||||
@ -20,6 +24,18 @@ const useEntitiesStore = create<EntitiesStore>((set) => ({
|
||||
selectEntity: (index) => set(() => ({ selectedEntity: index })),
|
||||
deselectEntity: () => set(() => ({ 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) =>
|
||||
set(({ entities }) => {
|
||||
const nextEntities = produce(entities, (draft) => {
|
||||
|
@ -105,10 +105,6 @@ body,
|
||||
transition: opacity 0.1s linear, filter 0.1s linear;
|
||||
}
|
||||
|
||||
.SliderThumb:hover {
|
||||
filter: drop-shadow(0px 10px 10px white);
|
||||
}
|
||||
|
||||
.SliderThumb::before {
|
||||
content: "";
|
||||
background-color: var(--indigo-400);
|
||||
|
@ -29,7 +29,7 @@ export function flattenedKeyframesByEntity(
|
||||
case "Text":
|
||||
keyframes.push(...flattenAnimatedVec2Keyframes(entity.origin));
|
||||
break;
|
||||
case "Box":
|
||||
case "Rect":
|
||||
keyframes.push(...flattenAnimatedVec2Keyframes(entity.position));
|
||||
keyframes.push(...flattenAnimatedVec2Keyframes(entity.size));
|
||||
break;
|
||||
@ -37,6 +37,21 @@ export function flattenedKeyframesByEntity(
|
||||
keyframes.push(...flattenAnimatedVec2Keyframes(entity.position));
|
||||
keyframes.push(...flattenAnimatedVec2Keyframes(entity.radius));
|
||||
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:
|
||||
break;
|
||||
}
|
||||
|
@ -16,7 +16,6 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"resolvePackageJsonExports": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
|
@ -1266,6 +1266,13 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 0.2.14
|
||||
resolution: "@unom/style@npm:0.2.14"
|
||||
@ -1379,6 +1386,7 @@ __metadata:
|
||||
"@types/node": "npm:^18.7.10"
|
||||
"@types/react": "npm:^18.0.15"
|
||||
"@types/react-dom": "npm:^18.0.6"
|
||||
"@types/uuid": "npm:^9"
|
||||
"@unom/style": "npm:^0.2.14"
|
||||
"@vitejs/plugin-react": "npm:^3.0.0"
|
||||
autoprefixer: "npm:^10.4.14"
|
||||
@ -1390,6 +1398,7 @@ __metadata:
|
||||
react-dom: "npm:^18.2.0"
|
||||
tailwindcss: "npm:^3.3.2"
|
||||
typescript: "npm:^4.9.5"
|
||||
uuid: "npm:^9.0.0"
|
||||
vite: "npm:^4.2.1"
|
||||
vite-tsconfig-paths: "npm:^4.2.0"
|
||||
zod: "npm:^3.21.4"
|
||||
@ -3378,6 +3387,15 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 4.2.0
|
||||
resolution: "vite-tsconfig-paths@npm:4.2.0"
|
||||
|
Loading…
x
Reference in New Issue
Block a user