This commit is contained in:
2023-05-20 14:11:35 +02:00
parent 7f6b7f4695
commit 7576850ae0
109 changed files with 10720 additions and 0 deletions

7
app/src/App.css Normal file
View File

@@ -0,0 +1,7 @@
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafb);
}

39
app/src/App.tsx Normal file
View File

@@ -0,0 +1,39 @@
import "./App.css";
import Timeline from "./components/Timeline";
import Canvas from "./components/Canvas";
import Properties, { PropertiesContainer } from "components/Properties";
import MenuBar from "components/MenuBar";
import ToolBar from "components/ToolBar";
import { useEffect } from "react";
import { invoke } from "@tauri-apps/api/tauri";
import { useFontsStore } from "stores/fonts.store";
export default function App() {
const { setFonts } = useFontsStore();
useEffect(() => {
invoke("get_system_fonts").then((data) => {
if (data && Array.isArray(data)) {
setFonts(data);
}
});
}, []);
return (
<div className="bg-gray-950 h-full w-full flex flex-col">
<MenuBar />
<div className="flex flex-row w-full h-full">
<ToolBar />
<div className="flex flex-col ml-4 w-full h-full">
<div className="flex gap-4 flex-row mb-4 justify-center items-center">
<Canvas />
<PropertiesContainer>
<Properties />
</PropertiesContainer>
</div>
<Timeline />
</div>
</div>
</div>
);
}

80
app/src/Old.tsx Normal file
View File

@@ -0,0 +1,80 @@
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>
);
}

1
app/src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,125 @@
import { FC } from "react";
import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api";
import { useTimelineStore } from "stores/timeline.store";
import InitCanvasKit, { CanvasKit } from "canvaskit-wasm";
import { Surface } from "canvaskit-wasm";
import drawText from "drawers/text";
import drawBox from "drawers/box";
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";
type CanvasProps = {};
const CanvasComponent: FC<CanvasProps> = () => {
const canvas = useRef<HTMLCanvasElement>(null);
const [loading, setLoading] = useState(true);
const [canvasKit, setCanvasKit] = useState<CanvasKit>();
const [fontData, setFontData] = useState<ArrayBuffer>();
const surface = useRef<Surface>();
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) => ({
entities: store.entities,
}));
useEffect(() => {
InitCanvasKit({
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")
.then((response) => response.arrayBuffer())
.then((arrayBuffer) => {
setFontData(arrayBuffer);
});
if (canvas.current) {
const CSurface = CanvasKit.MakeWebGLCanvasSurface(canvas.current);
if (CSurface) {
surface.current = CSurface;
}
}
});
}, []);
useEffect(() => {
// 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);
const entitiesResult = Entities.safeParse(data);
//console.time("draw");
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;
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");
});
});
return (
<div>
<div
className="flex items-center justify-center"
style={{ width: "100%", height: "500px" }}
>
<canvas
className="aspect-video h-full"
height={720}
width={1280}
ref={canvas}
></canvas>
</div>
</div>
);
};
export default CanvasComponent;

View File

@@ -0,0 +1,9 @@
const Loading = () => {
return (
<div>
<h2>Lädt Skia...</h2>
</div>
);
};
export default Loading;

View File

@@ -0,0 +1,129 @@
import { FC } from "react";
import * as Menubar from "@radix-ui/react-menubar";
import { ChevronRightIcon } from "@radix-ui/react-icons";
import { open, save } from "@tauri-apps/api/dialog";
const MenuBarTrigger: FC<{ label: string }> = ({ label }) => {
return (
<Menubar.Trigger className="py-2 dark:text-gray-300 px-3 transition-colors hover:bg-indigo-700 outline-none select-none font-medium leading-none rounded text-[13px] flex items-center justify-between gap-[2px]">
{label}
</Menubar.Trigger>
);
};
const MenuBarSubTrigger: FC<{ label: string }> = ({ label }) => {
return (
<Menubar.SubTrigger
className="group dark:text-gray-300 text-[13px] hover:bg-indigo-800 transition-colors leading-none
text-indigo11 rounded flex items-center h-[25px] px-[10px] relative select-none outline-none
data-[state=open]:bg-indigo data-[state=open]:text-white data-[highlighted]:bg-gradient-to-br
data-[highlighted]:from-indigo9 data-[highlighted]:to-indigo10 data-[highlighted]:text-indigo1
data-[highlighted]:data-[state=open]:text-indigo1 data-[disabled]:text-mauve8 data-[disabled]:pointer-events-none"
>
{label}
<div className="ml-auto pl-5 text-mauve9 group-data-[highlighted]:text-white group-data-[disabled]:text-mauve8">
<ChevronRightIcon />
</div>
</Menubar.SubTrigger>
);
};
const MenuBarItem: FC<{ label: string; onClick?: () => void }> = ({
label,
onClick,
}) => {
return (
<Menubar.Item
onClick={onClick}
className="group dark:text-white text-[13px] leading-none
rounded flex items-center h-[25px] px-[10px]
relative select-none outline-none hover:bg-indigo-800
data-[disabled]:pointer-events-none transition-colors"
>
{label}
</Menubar.Item>
);
};
const MenuBarSeperator = () => {
return <Menubar.Separator className="h-[1px] bg-slate-500 m-[5px]" />;
};
const MenuBar = () => {
const menuBarContentClassName =
"min-w-[220px] bg-gray-800 rounded-md p-[5px]";
const menuBarSubContentClassName =
"min-w-[220px] bg-gray-800 rounded-md p-[5px]";
return (
<Menubar.Root className="flex bg-gray-900 p-[3px] ">
<Menubar.Menu>
<MenuBarTrigger label="File" />
<Menubar.Portal>
<Menubar.Content
className={menuBarContentClassName}
align="start"
sideOffset={5}
alignOffset={-3}
>
<MenuBarItem label="New File" />
<MenuBarItem
onClick={() => open({ multiple: false })}
label="Open File"
/>
<MenuBarItem
onClick={() =>
save({
title: "Save Project",
defaultPath: "project.tbcp",
}).then((val) => {
console.log(val);
})
}
label="Save"
/>
<MenuBarItem onClick={() => save()} label="Save as" />
<MenuBarSeperator />
<Menubar.Sub>
<MenuBarSubTrigger label="Export as ..." />
<Menubar.Portal>
<Menubar.SubContent
className={menuBarSubContentClassName}
alignOffset={-5}
>
<MenuBarItem label=".mp4" />
<MenuBarItem label=".gif" />
<MenuBarItem label=".mov" />
<MenuBarItem label=".webm" />
<MenuBarItem label=".webp" />
</Menubar.SubContent>
</Menubar.Portal>
</Menubar.Sub>
</Menubar.Content>
</Menubar.Portal>
</Menubar.Menu>
<Menubar.Menu>
<MenuBarTrigger label="Edit" />
<Menubar.Portal>
<Menubar.Content
className={menuBarContentClassName}
align="start"
sideOffset={5}
alignOffset={-3}
>
<MenuBarItem label="Undo" />
<MenuBarItem label="Redo" />
<MenuBarItem label="Copy" />
<MenuBarItem label="Paste" />
</Menubar.Content>
</Menubar.Portal>
</Menubar.Menu>
</Menubar.Root>
);
};
export default MenuBar;

View File

@@ -0,0 +1,148 @@
import { ease } from "@unom/style";
import { motion } from "framer-motion";
import {
AnimatedTextEntity,
AnimatedBoxEntity,
AnimatedEllipseEntity,
} from "primitives/AnimatedEntities";
import { Paint, PaintStyle, PaintStyleType } from "primitives/Paint";
import { FC } from "react";
import { z } from "zod";
import { AnimatedVec2Properties } from "./Values";
import { PropertiesProps } from "./common";
type TextPropertiesProps = PropertiesProps<z.input<typeof AnimatedTextEntity>>;
type PaintPropertiesProps = PropertiesProps<z.input<typeof Paint>>;
type BoxPropertiesProps = PropertiesProps<z.input<typeof AnimatedBoxEntity>>;
type EllipsePropertiesProps = PropertiesProps<
z.input<typeof AnimatedEllipseEntity>
>;
export const PaintProperties: FC<PaintPropertiesProps> = ({
entity,
onUpdate,
}) => {
return (
<div>
<label className="flex flex-col items-start">
<span className="label">PaintStyle</span>
<select
value={entity.style.type}
onChange={(e) => {
if (entity.style.type !== e.target.value) {
const paintStyle = { type: e.target.value };
const parsedPaintStyle = PaintStyle.parse(paintStyle);
onUpdate({ style: parsedPaintStyle });
}
}}
>
{Object.keys(PaintStyleType.Values).map((key) => (
<option key={key} value={key}>
{key}
</option>
))}
</select>
</label>
</div>
);
};
export const TextProperties: FC<TextPropertiesProps> = ({
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 })}
/>
</label>
<label className="flex flex-col items-start">
<span className="label">Size</span>
<input
value={entity.paint.size}
onChange={(e) =>
onUpdate({
...entity,
paint: { ...entity.paint, size: Number(e.target.value) },
})
}
></input>
</label>
<AnimatedVec2Properties
onUpdate={(updatedEntity) =>
onUpdate({ ...entity, origin: updatedEntity })
}
label="Origin"
entity={entity.origin}
/>
</motion.div>
);
};
export const BoxProperties: FC<BoxPropertiesProps> = ({ entity, onUpdate }) => {
return (
<div className="dark:text-white">
<PaintProperties
entity={entity.paint}
onUpdate={(paint) =>
onUpdate({ ...entity, paint: { ...entity.paint, ...paint } })
}
/>
<AnimatedVec2Properties
onUpdate={(updatedEntity) =>
onUpdate({ ...entity, position: updatedEntity })
}
label="Position"
entity={entity.position}
/>
<AnimatedVec2Properties
onUpdate={(updatedEntity) =>
onUpdate({ ...entity, size: updatedEntity })
}
label="Size"
entity={entity.size}
/>
</div>
);
};
export const EllipseProperties: FC<EllipsePropertiesProps> = ({
entity,
onUpdate,
}) => {
return (
<div className="dark:text-white">
<PaintProperties
entity={entity.paint}
onUpdate={(paint) =>
onUpdate({ ...entity, paint: { ...entity.paint, ...paint } })
}
/>
<AnimatedVec2Properties
onUpdate={(updatedEntity) =>
onUpdate({ ...entity, position: updatedEntity })
}
label="Position"
entity={entity.position}
/>
<AnimatedVec2Properties
onUpdate={(updatedEntity) =>
onUpdate({ ...entity, radius: updatedEntity })
}
label="Size"
entity={entity.radius}
/>
</div>
);
};

View File

@@ -0,0 +1,176 @@
import { AnimatedNumber, AnimatedVec2 } from "primitives/Values";
import { PropertiesProps } from "./common";
import { FC } from "react";
import { z } from "zod";
import { produce } from "immer";
import { Interpolation } from "primitives/Interpolation";
import { Color } from "primitives/Paint";
const InterpolationProperties: FC<
PropertiesProps<z.input<typeof Interpolation>>
> = ({ entity, onUpdate }) => {
return <div>Interpolation: {entity.type}</div>;
};
const AnimatedNumberProperties: FC<
PropertiesProps<z.input<typeof AnimatedNumber>> & { label: string }
> = ({ entity, onUpdate, label }) => {
return (
<div>
<span>{label}</span>
{entity.keyframes.values.map((keyframe, index) => {
return (
<div key={index}>
<div className="flex flex-row gap-3">
<label className="flex flex-col items-start w-16">
<span className="label text-sm opacity-70">Offset</span>
<input
value={keyframe.offset}
onChange={(e) =>
onUpdate(
produce(entity, (draft) => {
draft.keyframes.values[index].offset = Number(
e.target.value
);
})
)
}
/>
</label>
<label className="flex flex-col items-start">
<span className="label text-sm opacity-70">Value</span>
<input
value={keyframe.value}
onChange={(e) =>
onUpdate(
produce(entity, (draft) => {
draft.keyframes.values[index].value = Number(
e.target.value
);
})
)
}
/>
</label>
</div>
{keyframe.interpolation && (
<InterpolationProperties
onUpdate={(updatedEntity) =>
onUpdate(
produce(entity, (draft) => {
draft.keyframes.values[index].interpolation =
updatedEntity;
})
)
}
entity={keyframe.interpolation}
/>
)}
</div>
);
})}
</div>
);
};
export const ColorProperties: FC<
PropertiesProps<z.input<typeof Color>> & {
label: string;
}
> = ({ entity, onUpdate }) => {
return (
<label className="flex flex-col items-start">
<span className="label">Color</span>
<div className="flex flex-row gap-3">
<input
value={entity.value[0]}
type="number"
max={255}
onChange={(e) =>
onUpdate(
produce(entity, (draft) => {
draft.value[0] = Number(e.target.value);
})
)
}
/>
<input
value={entity.value[1]}
type="number"
max={255}
onChange={(e) =>
onUpdate(
produce(entity, (draft) => {
draft.value[1] = Number(e.target.value);
})
)
}
/>
<input
value={entity.value[2]}
type="number"
max={255}
onChange={(e) =>
onUpdate(
produce(entity, (draft) => {
draft.value[2] = Number(e.target.value);
})
)
}
/>
<input
value={entity.value[3]}
type="number"
max={1}
onChange={(e) =>
onUpdate(
produce(entity, (draft) => {
draft.value[3] = Number(e.target.value);
})
)
}
/>
</div>
</label>
);
};
export const AnimatedVec2Properties: FC<
PropertiesProps<z.input<typeof AnimatedVec2>> & { label: string }
> = ({ entity, onUpdate, label }) => {
return (
<div>
<label className="flex flex-col items-start">
<span className="label">{label}</span>
<AnimatedNumberProperties
entity={entity.keyframes[0]}
label="X"
onUpdate={(updatedEntity) =>
onUpdate(
produce(entity, (draft) => {
draft.keyframes[0] = {
...draft.keyframes[0],
...updatedEntity,
};
})
)
}
/>
<AnimatedNumberProperties
entity={entity.keyframes[1]}
label="Y"
onUpdate={(updatedEntity) =>
onUpdate(
produce(entity, (draft) => {
draft.keyframes[1] = {
...draft.keyframes[1],
...updatedEntity,
};
})
)
}
/>
</label>
</div>
);
};

View File

@@ -0,0 +1,4 @@
export type PropertiesProps<E> = {
entity: E;
onUpdate: (entity: E) => void;
};

View File

@@ -0,0 +1,69 @@
import { FC, ReactNode } from "react";
import { useEntitiesStore } from "stores/entities.store";
import { shallow } from "zustand/shallow";
import { BoxProperties, EllipseProperties, TextProperties } from "./Primitives";
const PropertiesContainer: FC<{ children: ReactNode }> = ({ children }) => {
return (
<div className="w-full rounded-md h-[500px] overflow-auto border transition-colors focus-within:border-gray-400 border-gray-600 flex flex-col items-start p-4">
{children}
</div>
);
};
const Properties = () => {
const { selectedEntity, entities, updateEntity } = useEntitiesStore(
(store) => ({
updateEntity: store.updateEntity,
selectedEntity: store.selectedEntity,
entities: store.entities,
}),
shallow
);
const entity = selectedEntity !== undefined && entities[selectedEntity];
if (entity) {
switch (entity.type) {
case "Text":
return (
<TextProperties
key={selectedEntity}
onUpdate={(entity) => updateEntity(selectedEntity, entity)}
entity={entity}
/>
);
case "Box":
return (
<BoxProperties
key={selectedEntity}
onUpdate={(entity) => updateEntity(selectedEntity, entity)}
entity={entity}
/>
);
case "Ellipse":
return (
<EllipseProperties
key={selectedEntity}
onUpdate={(entity) => updateEntity(selectedEntity, entity)}
entity={entity}
/>
);
default:
return null;
}
}
return (
<div>
<h3>Wähle ein Element aus</h3>
</div>
);
};
export { PropertiesContainer };
export default Properties;

View File

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

View File

@@ -0,0 +1,211 @@
import { FC } from "react";
import { z } from "zod";
import { AnimationData } from "primitives/AnimatedEntities";
import { motion } from "framer-motion";
import TimePicker from "./TimePicker";
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 { flattenedKeyframesByEntity } from "utils";
export type AnimationEntity = {
offset: number;
duration: number;
};
type TimelineProps = {};
type TrackProps = {
animationData: z.input<typeof AnimationData>;
name: string;
index: number;
keyframes: Array<z.input<typeof Keyframe>>;
};
const KeyframeIndicator: FC<{
keyframe: z.input<typeof Keyframe>;
animationData: z.input<typeof AnimationData>;
}> = ({ keyframe, animationData }) => {
return (
<motion.div
animate={{
x: (animationData.offset + keyframe.offset) * 100 + 4,
}}
transition={ease.quint(0.4).out}
style={{
clipPath: "polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)",
}}
className="bg-indigo-300 absolute w-2 h-2 z-30 top-[39%]"
></motion.div>
);
};
const Track: FC<TrackProps> = ({ keyframes, animationData, index, name }) => {
const { updateEntity, selectEntity, selectedEntity, deselectEntity } =
useEntitiesStore(
(store) => ({
updateEntity: store.updateEntity,
selectedEntity: store.selectedEntity,
selectEntity: store.selectEntity,
deselectEntity: store.deselectEntity,
}),
shallow
);
return (
<div className="h-8 w-100 flex flex-row gap-1">
<div
onClick={() =>
selectedEntity !== undefined && selectedEntity === index
? deselectEntity()
: selectEntity(index)
}
className={`h-full transition-all rounded-sm flex-shrink-0 w-96 p-1 px-2 flex flex-row ${
selectedEntity === index ? "bg-gray-800" : "bg-gray-900"
}`}
>
<h3 className="text-white-800">{name}</h3>
</div>
<div
style={{ width: "1000px" }}
className="flex w-full h-full flex-row relative bg-gray-900"
>
{keyframes.map((keyframe, index) => (
<KeyframeIndicator
animationData={animationData}
keyframe={keyframe}
key={index}
/>
))}
<motion.div
drag="x"
animate={{
x: animationData.offset * 100,
}}
whileHover={{
scale: 1.1,
}}
whileTap={{
scale: 0.9,
}}
transition={ease.circ(0.6).out}
dragElastic={false}
dragConstraints={{ left: 0, right: 900 }}
onDragEnd={(e, info) => {
let offset = info.offset.x;
offset *= 0.01;
const animationOffset =
animationData.offset + offset < 0
? 0
: animationData.offset + offset;
const duration = animationData.duration - offset;
updateEntity(index, {
animation_data: {
...animationData,
offset: animationOffset < 0 ? 0 : animationOffset,
duration: duration < 0 ? 0 : duration,
},
});
}}
className="z-10 w-4 bg-slate-500 h-full absolute rounded-md"
/>
<motion.div
drag="x"
animate={{
x: (animationData.duration + animationData.offset) * 100 - 16,
}}
whileHover={{
scale: 1.1,
}}
whileTap={{
scale: 0.9,
}}
transition={ease.circ(0.6).out}
dragConstraints={{ left: 0, right: 900 }}
onDragEnd={(e, info) => {
let offset = info.offset.x;
offset *= 0.01;
const duration = animationData.duration + offset;
updateEntity(index, {
animation_data: {
...animationData,
duration: duration < 0 ? 0 : duration,
},
});
}}
className="z-10 w-4 bg-slate-500 h-full absolute rounded-md"
/>
<motion.div
drag="x"
animate={{
width: animationData.duration * 100,
x: animationData.offset * 100,
}}
whileHover={{ scaleY: 1.1 }}
whileTap={{ scaleY: 0.9 }}
dragConstraints={{
left: 0,
right: 900,
}}
transition={ease.circ(0.8).out}
onDragEnd={(e, info) => {
let offset = info.offset.x;
offset *= 0.01;
offset += animationData.offset;
console.log(offset);
updateEntity(index, {
animation_data: {
...animationData,
offset: offset < 0 ? 0 : offset,
},
});
}}
className="z-5 h-full absolute rounded-md transition-colors bg-gray-700 hover:bg-gray-600"
></motion.div>
</div>
</div>
);
};
const Timeline: FC<TimelineProps> = () => {
const { entities } = useEntitiesStore((store) => ({
entities: store.entities,
}));
return (
<div className="flex flex-col p-4 border transition-colors focus-within:border-gray-400 border-gray-600 rounded-md">
<Timestamp />
<div className="gap-1 flex flex-col overflow-hidden">
<div className="z-20 flex flex-row gap-2">
<div className="flex-shrink-0 w-96" />
<TimePicker />
</div>
{entities.map((entity, index) => (
<Track
name={entity.type}
index={index}
key={index}
keyframes={flattenedKeyframesByEntity(entity)}
animationData={entity.animation_data}
/>
))}
</div>
</div>
);
};
export default Timeline;

View File

@@ -0,0 +1,19 @@
import { useRenderStateStore } from "stores/render-state.store";
import { useTimelineStore } from "stores/timeline.store";
const Timestamp = () => {
const { renderState } = useRenderStateStore();
const timeline = useTimelineStore();
return (
<div>
<h3>Frame {renderState.curr_frame}</h3>
<h2 className="text-xl font-bold">
{((renderState.curr_frame * timeline.fps) / 60 / 60).toPrecision(3)}{" "}
<span className="text-sm font-light">/ {timeline.fps}FPS</span>
</h2>
</div>
);
};
export default Timestamp;

View File

@@ -0,0 +1,58 @@
import {
BoxIcon,
CircleIcon,
CursorArrowIcon,
MixIcon,
Pencil1Icon,
Pencil2Icon,
SymbolIcon,
TextIcon,
} from "@radix-ui/react-icons";
import * as Toolbar from "@radix-ui/react-toolbar";
import { FC, ReactNode } from "react";
const ToolBarButton: FC<{ children: ReactNode }> = ({ children }) => {
return (
<Toolbar.Button
className="text-white p-[10px] bg-gray-900 flex-shrink-0 flex-grow-0
basis-auto w-[40px] h-[40px] rounded inline-flex text-[13px] leading-none
items-center justify-center outline-none hover:bg-indigo-900
transition-colors
focus:relative focus:shadow-[0_0_0_2px] focus:shadow-indigo"
>
{children}
</Toolbar.Button>
);
};
const ToolBar = () => {
return (
<Toolbar.Root
className="bg-gray-800 flex flex-col gap-1 p-1 h-full"
orientation="vertical"
>
<ToolBarButton>
<CursorArrowIcon width="100%" height="100%" />
</ToolBarButton>
<Toolbar.Separator />
<ToolBarButton>
<BoxIcon width="100%" height="100%" />
</ToolBarButton>
<ToolBarButton>
<CircleIcon width="100%" height="100%" />
</ToolBarButton>
<ToolBarButton>
<Pencil1Icon width="100%" height="100%" />
</ToolBarButton>
<ToolBarButton>
<MixIcon width="100%" height="100%" />
</ToolBarButton>
<Toolbar.Separator />
<ToolBarButton>
<TextIcon width="100%" height="100%" />
</ToolBarButton>
</Toolbar.Root>
);
};
export default ToolBar;

47
app/src/drawers/box.ts Normal file
View File

@@ -0,0 +1,47 @@
import { Canvas, CanvasKit } from "canvaskit-wasm";
import { z } from "zod";
import { BoxEntity } from "primitives/Entities";
import { buildPaintStyle } from "./paint";
export default function drawBox(
CanvasKit: CanvasKit,
canvas: Canvas,
entity: z.infer<typeof BoxEntity>
) {
const paint = new CanvasKit.Paint();
const debugPaint = new CanvasKit.Paint();
debugPaint.setColor(CanvasKit.RED);
buildPaintStyle(CanvasKit, paint, entity.paint);
let targetPosition = entity.position;
canvas.drawCircle(targetPosition[0], targetPosition[1], 10, debugPaint);
targetPosition = targetPosition.map((val, index) => {
let temp = val - entity.size[index] * 0.5;
return temp;
});
debugPaint.setColor(CanvasKit.BLUE);
canvas.drawCircle(targetPosition[0], targetPosition[1], 10, debugPaint);
debugPaint.setColor(CanvasKit.GREEN);
canvas.drawCircle(targetPosition[0], targetPosition[1], 10, debugPaint);
console.log(targetPosition[0], targetPosition[1]);
const rect = CanvasKit.XYWHRect(
targetPosition[0],
targetPosition[1],
entity.size[0],
entity.size[1]
);
canvas.drawRect(rect, paint);
canvas.drawCircle(targetPosition[0], targetPosition[1], 10, debugPaint);
}

View File

@@ -0,0 +1,24 @@
import { convertToFloat } from "@tempblade/common";
import { Canvas, CanvasKit } from "canvaskit-wasm";
import { EllipseEntity } from "primitives/Entities";
import { z } from "zod";
import { buildPaintStyle } from "./paint";
export default function drawEllipse(
CanvasKit: CanvasKit,
canvas: Canvas,
entity: z.infer<typeof EllipseEntity>
) {
const paint = new CanvasKit.Paint();
buildPaintStyle(CanvasKit, paint, entity.paint);
const rect = CanvasKit.XYWHRect(
entity.position[0],
entity.position[1],
entity.radius[0],
entity.radius[1]
);
canvas.drawOval(rect, paint);
}

30
app/src/drawers/paint.ts Normal file
View File

@@ -0,0 +1,30 @@
import { convertToFloat } from "@tempblade/common";
import { Paint as SkPaint, CanvasKit } from "canvaskit-wasm";
import { Paint } from "primitives/Paint";
import { z } from "zod";
export function buildPaintStyle(
CanvasKit: CanvasKit,
skPaint: SkPaint,
paint: z.output<typeof Paint>
) {
const color = convertToFloat(paint.style.color.value);
skPaint.setAntiAlias(true);
skPaint.setColor(color);
switch (paint.style.type) {
case "Fill":
skPaint.setStyle(CanvasKit.PaintStyle.Fill);
break;
case "Stroke":
skPaint.setStyle(CanvasKit.PaintStyle.Stroke);
skPaint.setStrokeWidth(paint.style.width);
break;
default:
console.error("Paint Style not supported!");
break;
}
}

42
app/src/drawers/text.ts Normal file
View File

@@ -0,0 +1,42 @@
import { Canvas, CanvasKit } from "canvaskit-wasm";
import { TextEntity } from "primitives/Entities";
import { convertToFloat } from "@tempblade/common";
import { z } from "zod";
export default function drawText(
CanvasKit: CanvasKit,
canvas: Canvas,
entity: z.infer<typeof TextEntity>,
fontData: ArrayBuffer
) {
const fontMgr = CanvasKit.FontMgr.FromData(fontData);
if (!fontMgr) {
console.error("No FontMgr");
return;
}
const paint = new CanvasKit.Paint();
const color = convertToFloat(entity.paint.style.color.value);
paint.setColor(color);
const pStyle = new CanvasKit.ParagraphStyle({
textStyle: {
color: color,
fontFamilies: ["Roboto"],
fontSize: entity.paint.size,
},
textDirection: CanvasKit.TextDirection.LTR,
textAlign: CanvasKit.TextAlign[entity.paint.align],
});
const builder = CanvasKit.ParagraphBuilder.Make(pStyle, fontMgr);
builder.addText(entity.text);
const p = builder.build();
p.layout(900);
const height = p.getHeight() / 2;
const width = p.getMaxWidth() / 2;
canvas.drawParagraph(p, entity.origin[0] - width, entity.origin[1] - height);
}

190
app/src/example.ts Normal file
View File

@@ -0,0 +1,190 @@
import { AnimatedEntity } from "primitives/AnimatedEntities";
import { Color } from "primitives/Paint";
import { Timeline } from "primitives/Timeline";
import { staticAnimatedNumber, staticAnimatedVec2 } from "primitives/Values";
import { z } from "zod";
function buildBox1(
offset: number,
color: z.infer<typeof Color>
): z.input<typeof AnimatedEntity> {
return {
type: "Box",
paint: {
style: {
type: "Stroke",
width: 50,
color,
},
},
size: {
keyframes: [
{
keyframes: {
values: [
{
interpolation: {
type: "EasingFunction",
easing_function: "CircOut",
},
value: 0.0,
offset: 0.0,
},
{
interpolation: {
type: "Linear",
},
value: 1280.0,
offset: 4.0,
},
],
},
},
staticAnimatedNumber(720),
],
},
origin: staticAnimatedVec2(1280 / 2, 720 / 2),
position: staticAnimatedVec2(0, 0),
animation_data: {
offset,
duration: 10.0,
},
};
}
function buildBox(
offset: number,
color: z.infer<typeof Color>
): z.input<typeof AnimatedEntity> {
return {
type: "Box",
paint: {
style: {
type: "Fill",
color,
},
},
size: {
keyframes: [
{
keyframes: {
values: [
{
interpolation: {
type: "Linear",
},
value: 1280.0,
offset: 0.0,
},
],
},
},
{
keyframes: {
values: [
{
interpolation: {
type: "EasingFunction",
easing_function: "CircOut",
},
value: 0.0,
offset: 0.0,
},
{
interpolation: {
type: "Linear",
},
value: 720.0,
offset: 4.0,
},
],
},
},
],
},
origin: staticAnimatedVec2(0, -720),
position: staticAnimatedVec2(1280 / 2, 720 / 2),
animation_data: {
offset,
duration: 10.0,
},
};
}
function buildText(
text: string,
offset: number,
size: number,
y_offset: number,
color: z.infer<typeof Color>
): z.input<typeof AnimatedEntity> {
return {
type: "Text",
paint: {
style: {
type: "Fill",
color,
},
size,
align: "Center",
},
text,
animation_data: {
offset,
duration: 5.0,
},
origin: {
keyframes: [
{
keyframes: {
values: [
{
interpolation: {
type: "Spring",
mass: 1,
stiffness: 100,
damping: 15,
},
value: (1280 / 2) * -1 - 300,
offset: 0.0,
},
{
interpolation: {
type: "EasingFunction",
easing_function: "QuartOut",
},
value: 1280 / 2,
offset: 5.0,
},
],
},
},
staticAnimatedNumber(720 / 2 + y_offset),
],
},
};
}
export const EXAMPLE_ANIMATED_ENTITIES: Array<z.input<typeof AnimatedEntity>> =
[
buildText("Kleine Dumpfkopf!", 1.0, 80, -30, {
value: [255, 255, 255, 1.0],
}),
buildText("Wie gehts?", 1.5, 40, 30, { value: [255, 255, 255, 1.0] }),
buildBox(0.6, { value: [30, 30, 30, 1.0] }),
buildBox(0.4, { value: [20, 20, 20, 1.0] }),
buildBox(0.2, { value: [10, 10, 10, 1.0] }),
buildBox(0, { value: [0, 0, 0, 1.0] }),
];
const ExampleTimeline: z.input<typeof Timeline> = {
size: [1920, 1080],
duration: 10.0,
render_state: {
curr_frame: 20,
},
fps: 60,
entities: EXAMPLE_ANIMATED_ENTITIES,
};
export { ExampleTimeline };

10
app/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,37 @@
import { z } from "zod";
import { BoxEntity, EllipseEntity, TextEntity } from "./Entities";
import { AnimatedVec2 } from "./Values";
export const AnimationData = z.object({
offset: z.number(),
duration: z.number(),
visible: z.boolean().optional().default(true),
});
export const AnimatedBoxEntity = BoxEntity.extend({
position: AnimatedVec2,
size: AnimatedVec2,
origin: AnimatedVec2,
animation_data: AnimationData,
});
export const AnimatedTextEntity = TextEntity.extend({
origin: AnimatedVec2,
animation_data: AnimationData,
});
export const AnimatedEllipseEntity = EllipseEntity.extend({
radius: AnimatedVec2,
position: AnimatedVec2,
origin: AnimatedVec2,
animation_data: AnimationData,
});
export const AnimatedEntity = z.discriminatedUnion("type", [
AnimatedBoxEntity,
AnimatedTextEntity,
AnimatedEllipseEntity,
]);
export const AnimatedEntities = z.array(AnimatedEntity);

View File

@@ -0,0 +1,40 @@
import { z } from "zod";
import { Vec2 } from "./Values";
import { Paint, TextPaint } from "./Paint";
const EntityTypeOptions = ["Text", "Ellipse", "Box"] as const;
export const EntityType = z.enum(EntityTypeOptions);
export const GeometryEntity = z.object({
paint: Paint,
});
export const BoxEntity = GeometryEntity.extend({
type: z.literal(EntityType.Enum.Box),
size: Vec2,
position: Vec2,
origin: Vec2,
});
export const EllipseEntity = GeometryEntity.extend({
type: z.literal(EntityType.Enum.Ellipse),
radius: Vec2,
position: Vec2,
origin: Vec2,
});
export const TextEntity = z.object({
type: z.literal(EntityType.Enum.Text),
paint: TextPaint,
origin: Vec2,
text: z.string(),
});
export const Entity = z.discriminatedUnion("type", [
BoxEntity,
EllipseEntity,
TextEntity,
]);
export const Entities = z.array(Entity);

View File

@@ -0,0 +1,53 @@
import { z } from "zod";
const InterpolationTypeOptions = [
"Linear",
"Spring",
"EasingFunction",
] as const;
const EasingFunctionOptions = [
"QuintOut",
"QuintIn",
"QuintInOut",
"CircOut",
"CircIn",
"CircInOut",
"CubicOut",
"CubicIn",
"CubicInOut",
"ExpoOut",
"ExpoIn",
"ExpoInOut",
"QuadOut",
"QuadIn",
"QuadInOut",
"QuartOut",
"QuartIn",
"QuartInOut",
] as const;
export const EasingFunction = z.enum(EasingFunctionOptions);
export const InterpolationType = z.enum(InterpolationTypeOptions);
export const LinearInterpolation = z.object({
type: z.literal(InterpolationType.Enum.Linear),
});
export const EasingFunctionInterpolation = z.object({
type: z.literal(InterpolationType.Enum.EasingFunction),
easing_function: EasingFunction,
});
export const SpringInterpolation = z.object({
mass: z.number(),
damping: z.number(),
stiffness: z.number(),
type: z.literal(InterpolationType.Enum.Spring),
});
export const Interpolation = z.discriminatedUnion("type", [
SpringInterpolation,
EasingFunctionInterpolation,
LinearInterpolation,
]);

View File

@@ -0,0 +1,12 @@
import { z } from "zod";
import { Interpolation } from "./Interpolation";
export const Keyframe = z.object({
value: z.number(),
offset: z.number(),
interpolation: z.optional(Interpolation),
});
export const Keyframes = z.object({
values: z.array(Keyframe),
});

View File

@@ -0,0 +1,49 @@
import { z } from "zod";
export const Color = z.object({
value: z.array(z.number().min(0).max(255)).max(4),
});
const PaintStyleTypeOptions = ["Fill", "Stroke"] as const;
export const PaintStyleType = z.enum(PaintStyleTypeOptions);
const ColorWithDefault = Color.optional().default({ value: [0, 0, 0, 1] });
export const StrokeStyle = z.object({
width: z.number().min(0).optional().default(10),
color: ColorWithDefault,
type: z.literal(PaintStyleType.Enum.Stroke),
});
export const FillStyle = z.object({
color: ColorWithDefault,
type: z.literal(PaintStyleType.Enum.Fill),
});
export const TextAlign = z.enum(["Left", "Center", "Right"]);
export const PaintStyle = z.discriminatedUnion("type", [
StrokeStyle,
FillStyle,
]);
export const Paint = z.object({
style: PaintStyle,
});
export const TextPaint = z.object({
style: PaintStyle,
align: TextAlign,
size: z.number().min(0),
});
/* const NestedFillStyle = FillStyle.omit({ type: true }).default({});
const NestedStrokeStyle = StrokeStyle.omit({ type: true }).default({});
export const StrokeAndFillStyle = z.object({
color: ColorWithDefault,
type: z.literal(PaintStyleType.Enum.StrokeAndFill),
fill: NestedFillStyle,
stroke: NestedStrokeStyle,
}); */

View File

@@ -0,0 +1,14 @@
import { z } from "zod";
import { AnimatedEntities } from "./AnimatedEntities";
export const RenderState = z.object({
curr_frame: z.number(),
});
export const Timeline = z.object({
entities: AnimatedEntities,
render_state: RenderState,
duration: z.number(),
fps: z.number().int(),
size: z.array(z.number().int()).length(2),
});

View File

@@ -0,0 +1,67 @@
import { z } from "zod";
import { Keyframes } from "./Keyframe";
import { Interpolation } from "./Interpolation";
export const Vec2 = z.array(z.number()).length(2);
export const AnimatedNumber = z.object({
keyframes: Keyframes,
});
export const AnimatedVec2 = z.object({
keyframes: z.array(AnimatedNumber).length(2),
});
export function staticAnimatedNumber(
number: number
): z.infer<typeof AnimatedNumber> {
return {
keyframes: {
values: [
{
interpolation: {
type: "Linear",
},
value: number,
offset: 0,
},
],
},
};
}
export function staticAnimatedVec2(
x: number,
y: number
): z.infer<typeof AnimatedVec2> {
return {
keyframes: [
{
keyframes: {
values: [
{
interpolation: {
type: "Linear",
},
value: x,
offset: 0,
},
],
},
},
{
keyframes: {
values: [
{
interpolation: {
type: "Linear",
},
value: y,
offset: 0,
},
],
},
},
],
};
}

View File

@@ -0,0 +1,27 @@
import { useTimelineStore } from "stores/timeline.store";
import { useEntitiesStore } from "stores/entities.store";
import { useRenderStateStore } from "stores/render-state.store";
import { z } from "zod";
import { Timeline } from "primitives/Timeline";
export class ProjectService {
public saveProject() {
const timelineStore = useTimelineStore.getState();
const entitiesStore = useEntitiesStore.getState();
const renderStateStore = useRenderStateStore.getState();
const timeline: z.input<typeof Timeline> = {
...timelineStore,
entities: entitiesStore.entities,
render_state: renderStateStore.renderState,
};
const parsedTimeline = Timeline.parse(timeline);
const serializedTimeline = JSON.stringify(parsedTimeline);
return serializedTimeline;
}
public loadProject() {}
}

View File

@@ -0,0 +1,35 @@
import { EXAMPLE_ANIMATED_ENTITIES } from "example";
import { produce } from "immer";
import { AnimatedEntities, AnimatedEntity } from "primitives/AnimatedEntities";
import { z } from "zod";
import { create } from "zustand";
interface EntitiesStore {
entities: z.input<typeof AnimatedEntities>;
selectedEntity: number | undefined;
selectEntity: (index: number) => void;
deselectEntity: () => void;
updateEntity: (
index: number,
entity: Partial<z.input<typeof AnimatedEntity>>
) => void;
}
const useEntitiesStore = create<EntitiesStore>((set) => ({
entities: EXAMPLE_ANIMATED_ENTITIES,
selectEntity: (index) => set(() => ({ selectedEntity: index })),
deselectEntity: () => set(() => ({ selectedEntity: undefined })),
selectedEntity: undefined,
updateEntity: (index, entity) =>
set(({ entities }) => {
const nextEntities = produce(entities, (draft) => {
draft[index] = { ...draft[index], ...entity } as z.infer<
typeof AnimatedEntity
>;
});
return { entities: nextEntities };
}),
}));
export { useEntitiesStore };

View File

@@ -0,0 +1,13 @@
import { create } from "zustand";
interface FontsStore {
fonts: Array<string>;
setFonts: (fonts: Array<string>) => void;
}
const useFontsStore = create<FontsStore>((set) => ({
fonts: [],
setFonts: (fonts) => ({ fonts }),
}));
export { useFontsStore };

View File

@@ -0,0 +1,24 @@
import { RenderState } from "primitives/Timeline";
import { z } from "zod";
import { create } from "zustand";
interface RenderStateStore {
renderState: z.infer<typeof RenderState>;
setCurrentFrame: (target: number) => void;
}
const useRenderStateStore = create<RenderStateStore>((set) => ({
renderState: {
curr_frame: 20,
},
setCurrentFrame: (target) =>
set((store) => {
store.renderState = {
curr_frame: target,
};
return { renderState: store.renderState };
}),
}));
export { useRenderStateStore };

View File

@@ -0,0 +1,15 @@
import { create } from "zustand";
interface TimelineStore {
fps: number;
duration: number;
size: [number, number];
}
const useTimelineStore = create<TimelineStore>((set) => ({
fps: 60,
size: [1920, 1080],
duration: 10.0,
}));
export { useTimelineStore };

137
app/src/styles.css Normal file
View File

@@ -0,0 +1,137 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
h1 {
@apply text-2xl;
}
h2 {
@apply text-xl;
}
h3 {
@apply text-lg;
}
h1,
h2,
h3,
h4,
h5,
h6,
a,
label {
@apply dark:text-white;
}
a {
@apply text-blue-600 underline;
}
select,
input {
@apply box-border bg-gray-900 shadow-indigo-100 hover:shadow-indigo-400
focus:shadow-indigo-600 selection:bg-indigo-400 selection:text-black
outline-none px-3 py-2 rounded-md shadow-[0_0_0_1px];
}
input {
@apply appearance-none items-center justify-center
w-full text-base leading-none
text-white transition-all;
}
select {
@apply appearance-none;
}
}
.label {
@apply mb-1;
}
label {
@apply mb-2;
}
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
html,
body,
#root {
width: 100%;
height: 100%;
}
.SliderRoot {
position: relative;
display: flex;
align-items: center;
user-select: none;
touch-action: none;
width: 200px;
height: 20px;
}
.SliderTrack {
background-color: var(--black);
position: relative;
flex-grow: 1;
border-radius: 9999px;
height: 3px;
}
.SliderRange {
position: absolute;
background-color: #ddd;
border-radius: 9999px;
height: 100%;
}
.SliderThumb {
position: relative;
z-index: 100;
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);
width: 20px;
height: 20px;
position: absolute;
left: -8.5px;
top: -10px;
clip-path: polygon(100% 0, 0 0, 50% 75%);
display: block;
@apply bg-indigo-300;
}
.SliderThumb::after {
content: "";
position: absolute;
top: 0;
width: 3px;
height: 1000px;
z-index: 200;
opacity: 1;
@apply bg-indigo-300;
}

45
app/src/utils/index.ts Normal file
View File

@@ -0,0 +1,45 @@
import { AnimatedEntity } from "primitives/AnimatedEntities";
import { Keyframe } from "primitives/Keyframe";
import { AnimatedNumber, AnimatedVec2 } from "primitives/Values";
import { z } from "zod";
export function flattenAnimatedNumberKeyframes(
aNumber: z.input<typeof AnimatedNumber>
): Array<z.input<typeof Keyframe>> {
return aNumber.keyframes.values;
}
export function flattenAnimatedVec2Keyframes(
aVec2: z.input<typeof AnimatedVec2>
): Array<z.input<typeof Keyframe>> {
const keyframes: Array<z.input<typeof Keyframe>> = [
...flattenAnimatedNumberKeyframes(aVec2.keyframes[0]),
...flattenAnimatedNumberKeyframes(aVec2.keyframes[1]),
];
return keyframes;
}
export function flattenedKeyframesByEntity(
entity: z.input<typeof AnimatedEntity>
): Array<z.input<typeof Keyframe>> {
const keyframes: Array<z.input<typeof Keyframe>> = [];
switch (entity.type) {
case "Text":
keyframes.push(...flattenAnimatedVec2Keyframes(entity.origin));
break;
case "Box":
keyframes.push(...flattenAnimatedVec2Keyframes(entity.position));
keyframes.push(...flattenAnimatedVec2Keyframes(entity.size));
break;
case "Ellipse":
keyframes.push(...flattenAnimatedVec2Keyframes(entity.position));
keyframes.push(...flattenAnimatedVec2Keyframes(entity.radius));
break;
default:
break;
}
return keyframes;
}

1
app/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />