import type { CSSProperties, ReactNode } from "react";
import { Easing, continueRender, delayRender, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
import { roughCircle, roughLine, roughRoundedRect, variants } from "drawably";
import type { RoughOptions } from "drawably";
import penUrl from "../font/DrawablyPen.ttf";
// brand
export const PAPER = "#e3e3e1";
export const PEN = "#2724d1";
export const ERROR = "#d12724";
export const SUCCESS = "#188a42";
export const INK = "#18181b";
export const INK_2 = "#474645";
export const INK_3 = "#a1a1aa";
export const SANS = 'Geist, Inter, "Helvetica Neue", system-ui, sans-serif';
export const MONO = '"Geist Mono", "SFMono-Regular", Menlo, monospace';
// everything lettered inside a sketch is in the pen, like the site's demos;
// headlines stay in the UI face, code in mono
export const PEN_FACE = '"Drawably Pen", Geist, sans-serif';
const penFont = delayRender("Drawably Pen");
new FontFace("Drawably Pen", `url(${penUrl})`).load().then((face) => {
document.fonts.add(face);
continueRender(penFont);
});
export const WIDTH = 1920;
export const HEIGHT = 1080;
export const FPS = 30;
// strokes are drawn ~3× the size the library draws them at; roughness and
// boil scale up with them or the wobble vanishes on a 1080p canvas
export const ROUGH = { roughness: 1.15, boil: 0.5 };
// the library's 1200 ms three-frame cycle, in frames of whatever rate the film runs at
const BOIL_SECONDS = 0.4;
export const boilFrames = (fps: number) => Math.round(fps * BOIL_SECONDS);
export const boilIndex = (frame: number, fps: number, n = 3) => Math.floor(frame / boilFrames(fps)) % n;
export const INSET = 3;
export const HERO = { w: 440, h: 112, r: 12, stroke: 3.5 };
export const ease = Easing.bezier(0.2, 0, 0, 1); // --drawably-ease
export const clamp = { extrapolateLeft: "clamp", extrapolateRight: "clamp" } as const;
export const ramp = (frame: number, from: number, to: number) =>
interpolate(frame, [from, to], [0, 1], { ...clamp, easing: ease });
// a pen travels at a steady speed with soft ends, unlike UI which decelerates
export const pen = (frame: number, from: number, to: number) =>
interpolate(frame, [from, to], [0, 1], { ...clamp, easing: Easing.inOut(Easing.sin) });
export const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
export type Gen = (o: RoughOptions) => string;
export const rect =
(w: number, h: number, r: number): Gen =>
(o) =>
roughRoundedRect(INSET, INSET, w - 2 * INSET, h - 2 * INSET, r, o);
export function Rough({
gen,
seed,
draw = 1,
color = PEN,
width = 3,
fill = "none",
opacity = 1,
rough = ROUGH,
}: {
gen: Gen;
seed: number;
draw?: number;
color?: string;
width?: number;
fill?: string;
opacity?: number;
rough?: { roughness: number; boil: number };
}) {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const frames = variants(gen, { ...rough, seed });
return (
);
}
export function Svg({ x, y, w, h, style, children }: { x: number; y: number; w: number; h: number; style?: CSSProperties; children: ReactNode }) {
return (
);
}
// rise in, settle, blur out. `out` is optional: some things stay to the cut
export function Rise({
from,
dur = 14,
out,
outDur = 10,
dy = 28,
style,
children,
}: {
from: number;
dur?: number;
out?: number;
outDur?: number;
dy?: number;
style?: CSSProperties;
children: ReactNode;
}) {
const frame = useCurrentFrame();
const enter = ramp(frame, from, from + dur);
const exit = out === undefined ? 0 : ramp(frame, out, out + outDur);
const v = enter * (1 - exit);
return (
{children}
);
}
export const headline: CSSProperties = {
left: 0,
right: 0,
color: INK,
fontFamily: SANS,
fontSize: 104,
fontWeight: 500,
letterSpacing: "-0.05em",
lineHeight: 1,
textAlign: "center",
};
export const caption: CSSProperties = {
left: 0,
right: 0,
color: INK_2,
fontFamily: MONO,
fontSize: 19,
letterSpacing: "0.16em",
textAlign: "center",
textTransform: "uppercase",
};
export function Grain() {
return (
);
}
// the library's toggle at ~4.2x: a 76x40 track, a 14 r knob, 36 of travel
export const TOGGLE = { w: 560, h: 294, stroke: 5, knob: 103, travel: 266, gap: 34 };
export const TOGGLE_X = (WIDTH - TOGGLE.w) / 2;
export const TOGGLE_MID = 430;
export const TOGGLE_Y = TOGGLE_MID - TOGGLE.h / 2;
export const toggleTrack = rect(TOGGLE.w, TOGGLE.h, (TOGGLE.h - 2 * INSET) / 2);
export function HeroToggle({
seed,
draw = 1,
knobDraw = 1,
on = 0,
color = PEN,
label,
labelOpacity = 1,
opacity = 1,
}: {
seed: number;
draw?: number;
knobDraw?: number;
on?: number;
color?: string;
label?: string;
labelOpacity?: number;
opacity?: number;
}) {
return (
{label && (
{label}
)}
);
}
export const HEADLINE_Y = 760;
export const CURSOR = "M0 0 L0 20.5 L5 15.8 L8.6 24 L11.6 22.7 L8 14.6 L14.8 14.6 Z";
export type Pt = [number, number];
export interface Seg { len: number; at(t: number): Pt; }
export const QUAD_STEPS = 16;
export function segments(d: string): Seg[] {
const segs: Seg[] = [];
let cur: Pt = [0, 0];
let start: Pt = [0, 0];
const line = (a: Pt, b: Pt): Seg => ({ len: Math.hypot(b[0] - a[0], b[1] - a[1]), at: (t) => [lerp(a[0], b[0], t), lerp(a[1], b[1], t)] });
const quad = (a: Pt, c: Pt, b: Pt): Seg => {
const at = (t: number): Pt => [
(1 - t) ** 2 * a[0] + 2 * (1 - t) * t * c[0] + t * t * b[0],
(1 - t) ** 2 * a[1] + 2 * (1 - t) * t * c[1] + t * t * b[1],
];
let len = 0;
let prev = a;
for (let i = 1; i <= QUAD_STEPS; i++) {
const p = at(i / QUAD_STEPS);
len += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
prev = p;
}
return { len, at };
};
for (const [, cmd, rest] of d.matchAll(/([MLQZ])([^MLQZ]*)/g)) {
const n = rest.trim().split(/[\s,]+/).filter(Boolean).map(Number);
if (cmd === "M") cur = start = [n[0], n[1]];
else if (cmd === "L") { segs.push(line(cur, [n[0], n[1]])); cur = [n[0], n[1]]; }
else if (cmd === "Q") { segs.push(quad(cur, [n[0], n[1]], [n[2], n[3]])); cur = [n[2], n[3]]; }
else if (cmd === "Z") { segs.push(line(cur, start)); cur = start; }
}
return segs;
}
export function totalLength(segs: Seg[]): number {
return segs.reduce((sum, seg) => sum + seg.len, 0);
}
export function pointAt(segs: Seg[], fraction: number): Pt {
return pointAtLength(segs, Math.min(fraction, 1) * totalLength(segs));
}
export function pointAtLength(segs: Seg[], length: number): Pt {
let target = Math.max(0, length);
for (const seg of segs) {
if (target <= seg.len) return seg.at(seg.len ? target / seg.len : 0);
target -= seg.len;
}
return segs[segs.length - 1].at(1);
}
/* ---------- 7. close: the wordmark writes itself ---------- */
// the site's pen-lettered wordmark on a 100-unit grid: monoline lowercase,
// baseline 100, one stroke per pen lift so the film can draw them in order
const X_TOP = 45;
const BASE = 100;
export const ASC = 0;
export const DESC = 135;
const BOWL = (BASE - X_TOP) / 2;
const GAP = 12;
type Stroke = (x: number, o: RoughOptions) => string;
const wline =
(x1: number, y1: number, x2: number, y2: number): Stroke =>
(x, o) =>
roughLine(x + x1, y1, x + x2, y2, o);
const stem = (dx: number, top: number) => wline(dx, top, dx, BASE);
const bowl: Stroke = (x, o) => roughCircle(x + BOWL, X_TOP + BOWL, BOWL, o);
const LETTERS: Record = {
d: { w: BOWL * 2, strokes: [bowl, stem(BOWL * 2, ASC)] },
r: { w: 32, strokes: [stem(0, X_TOP), wline(0, 64, 14, 48), wline(14, 48, 32, 46)] },
a: { w: BOWL * 2, strokes: [bowl, stem(BOWL * 2, X_TOP)] },
w: { w: 68, strokes: [wline(0, X_TOP, 17, BASE), wline(17, BASE, 34, 52), wline(34, 52, 51, BASE), wline(51, BASE, 68, X_TOP)] },
b: { w: BOWL * 2, strokes: [stem(0, ASC), bowl] },
l: { w: 0, strokes: [stem(0, ASC)] },
y: { w: 52, strokes: [wline(0, X_TOP, 26, BASE), wline(52, X_TOP, 12, DESC)] },
};
function wordStrokes(word: string): { gen: Gen; seed: number }[] {
let x = 0;
const out: { gen: Gen; seed: number }[] = [];
for (const ch of word) {
const glyph = LETTERS[ch];
const x0 = x;
for (const stroke of glyph.strokes) out.push({ gen: (o) => stroke(x0, o), seed: 42 + out.length });
x += glyph.w + GAP;
}
return out;
}
export const WORD = wordStrokes("drawably");
export const WORD_W = "drawably".split("").reduce((w, ch) => w + LETTERS[ch].w + GAP, 0) - GAP;
export const WORD_PAD = 10;
const WORD_SCALE = 3.3;
// the wordmark's units are ~3.3 px, so its roughness is per unit
export const WORD_ROUGH = { roughness: 0.75, boil: 0.5 };
const STROKE_EVERY = 5;
const STROKE_DUR = 13;
export function Close() {
// tuned at 30 fps; runs at the same speed whatever the composition's rate
const { fps } = useVideoConfig();
const k = fps / 30;
const frame = useCurrentFrame() / k;
const vw = WORD_W + WORD_PAD * 2;
const vh = DESC - ASC + WORD_PAD * 2;
const w = vw * WORD_SCALE;
const h = vh * WORD_SCALE;
const pill = { w: 420, h: 68, r: 10 };
const pillIn = ramp(frame, 94, 110);
return (
<>
hand-drawn ui controls
>
);
}