diff --git a/app/.vscode/extensions.json b/app/.vscode/extensions.json index 24d7cc6..b864f8c 100644 --- a/app/.vscode/extensions.json +++ b/app/.vscode/extensions.json @@ -1,3 +1,6 @@ { - "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] -} + "recommendations": [ + "tauri-apps.tauri-vscode", + "rust-lang.rust-analyzer" + ] +} \ No newline at end of file diff --git a/app/.yarn/install-state.gz b/app/.yarn/install-state.gz index 23a378b..b244af4 100644 Binary files a/app/.yarn/install-state.gz and b/app/.yarn/install-state.gz differ diff --git a/app/package.json b/app/package.json index c039005..ddbefaa 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src-tauri/src/animation/primitives/entities/common.rs b/app/src-tauri/src/animation/primitives/entities/common.rs index 4f2f533..4dadd90 100644 --- a/app/src-tauri/src/animation/primitives/entities/common.rs +++ b/app/src-tauri/src/animation/primitives/entities/common.rs @@ -37,6 +37,11 @@ pub trait Animateable { fn calculate(&mut self, timeline: &Timeline) -> Option; } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Cache { + pub valid: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum AnimatedEntity { diff --git a/app/src-tauri/src/animation/primitives/entities/ellipse.rs b/app/src-tauri/src/animation/primitives/entities/ellipse.rs index 5da930b..a200041 100644 --- a/app/src-tauri/src/animation/primitives/entities/ellipse.rs +++ b/app/src-tauri/src/animation/primitives/entities/ellipse.rs @@ -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, })) diff --git a/app/src-tauri/src/animation/primitives/entities/rect.rs b/app/src-tauri/src/animation/primitives/entities/rect.rs index 9114000..167c8ee 100644 --- a/app/src-tauri/src/animation/primitives/entities/rect.rs +++ b/app/src-tauri/src/animation/primitives/entities/rect.rs @@ -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, diff --git a/app/src-tauri/src/animation/primitives/entities/staggered_text.rs b/app/src-tauri/src/animation/primitives/entities/staggered_text.rs index 04a96c1..fd6b7a6 100644 --- a/app/src-tauri/src/animation/primitives/entities/staggered_text.rs +++ b/app/src-tauri/src/animation/primitives/entities/staggered_text.rs @@ -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, + pub transform: Option>, 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, 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, pub animation_data: AnimationData, pub letter: StaggeredTextLetter, @@ -51,14 +58,36 @@ impl Animateable for AnimatedStaggeredTextEntity { None => None, }; - let letter_transform: Option = 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> = match self.letter.transform.clone() { + Some(mut val) => { + let mut transforms: Vec = 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 { diff --git a/app/src-tauri/src/animation/primitives/entities/text.rs b/app/src-tauri/src/animation/primitives/entities/text.rs index ef138ea..15969d5 100644 --- a/app/src-tauri/src/animation/primitives/entities/text.rs +++ b/app/src-tauri/src/animation/primitives/entities/text.rs @@ -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, diff --git a/app/src-tauri/src/animation/timeline.rs b/app/src-tauri/src/animation/timeline.rs index 4092c27..617fecc 100644 --- a/app/src-tauri/src/animation/timeline.rs +++ b/app/src-tauri/src/animation/timeline.rs @@ -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, diff --git a/app/src-tauri/src/fonts.rs b/app/src-tauri/src/fonts.rs index 597edc6..dcde1f3 100644 --- a/app/src-tauri/src/fonts.rs +++ b/app/src-tauri/src/fonts.rs @@ -12,3 +12,27 @@ pub fn get_system_fonts() -> Option> { found_families.ok() } + +#[tauri::command] +pub fn get_system_font(font_name: String) -> Option> { + 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, + } +} diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs index 9c07ca0..a2aac92 100644 --- a/app/src-tauri/src/main.rs +++ b/app/src-tauri/src/main.rs @@ -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!()) diff --git a/app/src/Old.tsx b/app/src/Old.tsx deleted file mode 100644 index 1ab3b3b..0000000 --- a/app/src/Old.tsx +++ /dev/null @@ -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 = [ - { - offset: 0.2, - duration: 1.0, - }, -]; - -function App() { - const canvas = useRef(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 ( -
-
- -
- setTitle(e.target.value)} /> - setSubTitle(e.target.value)} /> - -
- ); -} diff --git a/app/src/components/Canvas/index.tsx b/app/src/components/Canvas/index.tsx index 94734b3..0fb1f0d 100644 --- a/app/src/components/Canvas/index.tsx +++ b/app/src/components/Canvas/index.tsx @@ -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 = () => { const canvas = useRef(null); @@ -21,15 +33,17 @@ const CanvasComponent: FC = () => { const [canvasKit, setCanvasKit] = useState(); const [fontData, setFontData] = useState(); const surface = useRef(); - + const staggeredTextCache = useMap(); + const isLocked = useRef(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 = () => { 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 (
diff --git a/app/src/components/Properties/Primitives.tsx b/app/src/components/Properties/Primitives.tsx index 8920392..2f69299 100644 --- a/app/src/components/Properties/Primitives.tsx +++ b/app/src/components/Properties/Primitives.tsx @@ -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>; +type StaggeredTextPropertiesProps = PropertiesProps< + z.input +>; type PaintPropertiesProps = PropertiesProps>; -type BoxPropertiesProps = PropertiesProps>; +type RectPropertiesProps = PropertiesProps>; type EllipsePropertiesProps = PropertiesProps< z.input >; @@ -45,6 +49,15 @@ export const PaintProperties: FC = ({ ))} + {entity.style.color && ( + + onUpdate({ ...entity, style: { ...entity.style, color } }) + } + entity={entity.style.color} + /> + )}
); }; @@ -90,7 +103,75 @@ export const TextProperties: FC = ({ ); }; -export const BoxProperties: FC = ({ entity, onUpdate }) => { +export const StaggeredTextProperties: FC = ({ + entity, + onUpdate, +}) => { + return ( + + + + + onUpdate({ + ...entity, + letter: { + ...entity.letter, + paint: { ...entity.letter.paint, ...paint }, + }, + }) + } + /> + + onUpdate({ ...entity, origin: updatedEntity }) + } + label="Origin" + entity={entity.origin} + /> + + ); +}; + +export const RectProperties: FC = ({ + entity, + onUpdate, +}) => { return (
= ({ children }) => { return ( @@ -26,6 +31,15 @@ const Properties = () => { if (entity) { switch (entity.type) { + case "StaggeredText": + return ( + updateEntity(selectedEntity, entity)} + entity={entity} + /> + ); + case "Text": return ( { /> ); - case "Box": + case "Rect": return ( - updateEntity(selectedEntity, entity)} entity={entity} diff --git a/app/src/components/Timeline.tsx b/app/src/components/Timeline.tsx index 261593e..7dc98a3 100644 --- a/app/src/components/Timeline.tsx +++ b/app/src/components/Timeline.tsx @@ -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" - > + 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 = ({ keyframes, animationData, index, name }) => { ); return ( -
+
selectedEntity !== undefined && selectedEntity === index @@ -90,6 +90,7 @@ const Track: FC = ({ 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 = ({ 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" /> e.preventDefault()} drag="x" animate={{ x: (animationData.duration + animationData.offset) * 100 - 16, @@ -142,7 +144,7 @@ const Track: FC = ({ 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" /> = ({ 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 = ({ 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" >
diff --git a/app/src/drawers/draw.ts b/app/src/drawers/draw.ts new file mode 100644 index 0000000..4ffda0e --- /dev/null +++ b/app/src/drawers/draw.ts @@ -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 | undefined; + private ckDidLoad: boolean; + private dependenciesDidLoad: boolean; + private CanvasKit: CanvasKit | undefined; + cache: { staggeredText: Map }; + + 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) { + 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) { + } + } +} diff --git a/app/src/drawers/staggered-text.ts b/app/src/drawers/staggered-text.ts index d05b15a..5195864 100644 --- a/app/src/drawers/staggered-text.ts +++ b/app/src/drawers/staggered-text.ts @@ -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; + 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, + maxWidth: number +): Array { + const measuredLetters: Array = []; + + 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, 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 = {}; + + 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 = {}; + 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, + font: Font, + measuredLetters: Array, + 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, + 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(); + } + } } diff --git a/app/src/drawers/text.ts b/app/src/drawers/text.ts index a67d905..f3e3922 100644 --- a/app/src/drawers/text.ts +++ b/app/src/drawers/text.ts @@ -9,6 +9,7 @@ export default function drawText( entity: z.output, 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(); } diff --git a/app/src/example.ts b/app/src/example.ts index d0a5c77..e9e091b 100644 --- a/app/src/example.ts +++ b/app/src/example.ts @@ -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 ): z.input { return { + id: uuid(), + cache: {}, type: "Rect", paint: { style: { @@ -58,6 +61,8 @@ function buildRect( ): z.input { return { type: "Rect", + id: uuid(), + cache: {}, paint: { style: { type: "Fill", @@ -126,6 +131,8 @@ function buildText( ): z.input { 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> = [ - 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] }), diff --git a/app/src/hooks/useMap.ts b/app/src/hooks/useMap.ts new file mode 100644 index 0000000..ab0154f --- /dev/null +++ b/app/src/hooks/useMap.ts @@ -0,0 +1,53 @@ +import { useCallback, useState } from "react"; + +export type MapOrEntries = Map | [K, V][]; + +// Public interface +export interface Actions { + set: (key: K, value: V) => void; + setAll: (entries: MapOrEntries) => void; + remove: (key: K) => void; + reset: Map["clear"]; +} + +// We hide some setters from the returned map to disable autocompletion +type Return = [ + Omit, "set" | "clear" | "delete">, + Actions +]; + +function useMap( + initialState: MapOrEntries = new Map() +): Return { + const [map, setMap] = useState(new Map(initialState)); + + const actions: Actions = { + 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; diff --git a/app/src/primitives/AnimatedEntities.ts b/app/src/primitives/AnimatedEntities.ts index fa6058c..49db574 100644 --- a/app/src/primitives/AnimatedEntities.ts +++ b/app/src/primitives/AnimatedEntities.ts @@ -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, ]); diff --git a/app/src/primitives/Entities.ts b/app/src/primitives/Entities.ts index b2dda58..34dca68 100644 --- a/app/src/primitives/Entities.ts +++ b/app/src/primitives/Entities.ts @@ -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); diff --git a/app/src/stores/entities.store.ts b/app/src/stores/entities.store.ts index 47a8ca0..2ee67dd 100644 --- a/app/src/stores/entities.store.ts +++ b/app/src/stores/entities.store.ts @@ -13,6 +13,10 @@ interface EntitiesStore { index: number, entity: Partial> ) => void; + updateEntityById: ( + id: string, + entity: Partial> + ) => void; } const useEntitiesStore = create((set) => ({ @@ -20,6 +24,18 @@ const useEntitiesStore = create((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) => { diff --git a/app/src/styles.css b/app/src/styles.css index 7ef1ab8..83b3e32 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -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); diff --git a/app/src/utils/index.ts b/app/src/utils/index.ts index 1d88d81..6061c97 100644 --- a/app/src/utils/index.ts +++ b/app/src/utils/index.ts @@ -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; } diff --git a/app/tsconfig.json b/app/tsconfig.json index 7900a51..82bd78a 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -16,7 +16,6 @@ "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "Node", - "resolvePackageJsonExports": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, diff --git a/app/yarn.lock b/app/yarn.lock index 689c769..882b88b 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -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"