add system font loading

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

@@ -1,5 +1,11 @@
import { z } from "zod";
import { 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,
]);

View File

@@ -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);

View File

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

View File

@@ -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);

View File

@@ -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;
}