/**
* Doodle arrow + handwritten note / חץ מצויר עם כיתוב (student motion library, id "stk-doodle-arrow")
* Use: copy this file into your Remotion project and register it in your Root:
*
* Render transparent: npx remotion render src/index.ts LibStkArrow out.webm --codec=vp9 --pixel-format=yuva420p --image-format=png --props=props.json
* Change the words / colours / side in props.json (same keys as arrowDefaults). A vertical video: width 1080, height 1920.
*/
import {loadFont as loadHand} from '@remotion/google-fonts/GveretLevin';
// ---------------------------------------------------------------- helpers (shared by every library scene)
// Conventions: 1920x1080, 30 fps, transparent background. Sizes follow the frame's short side, so 1080x1920 works too.
// Common props: duration (s), side 'right' | 'left' | 'center', x / y (centre in % of the frame, override side),
// scale, font (a CSS font-family; default Heebo). Time is in seconds; the first and last frames are empty.
import React from 'react';
import {AbsoluteFill, Easing, useCurrentFrame, useVideoConfig} from 'remotion';
import {loadFont as loadHeebo} from '@remotion/google-fonts/Heebo';
const heebo = loadHeebo('normal', {weights: ['400', '500', '700', '800', '900'], subsets: ['hebrew', 'latin']});
/** Hebrew + Latin font (Google "Heebo"). props.font overrides it per scene. */
export const HEEBO = `${heebo.fontFamily}, "Helvetica Neue", Arial, sans-serif`;
export const MONO = 'ui-monospace, "SF Mono", Menlo, Consolas, monospace';
export type Side = 'right' | 'left' | 'center';
export type Base = {duration?: number; side?: Side; x?: number; y?: number; scale?: number; font?: string};
export const clamp01 = (x: number) => Math.min(1, Math.max(0, x));
/** 0..1 progress of t between a and b (seconds) */
export const p01 = (t: number, a: number, b: number) => clamp01((t - a) / (b - a));
export const lerp = (a: number, b: number, k: number) => a + (b - a) * k;
export const outE = Easing.bezier(0.16, 1, 0.3, 1); // fast out, long settle
export const inOut = Easing.bezier(0.65, 0, 0.35, 1);
export const inE = Easing.bezier(0.55, 0, 0.9, 0.45);
export const backOut = (x: number, s = 1.7) => (x <= 0 ? 0 : x >= 1 ? 1 : 1 + (s + 1) * Math.pow(x - 1, 3) + s * Math.pow(x - 1, 2));
/** Damped spring 0 -> 1 starting at t0 (closed form, so any frame computes on its own). */
export const spr = (t: number, t0: number, freq = 16, damp = 9) => {
const x = t - t0;
return x <= 0 ? 0 : 1 - Math.exp(-damp * x) * Math.cos(freq * x);
};
/** Deterministic pseudo random 0..1 */
export const rnd = (i: number, seed = 1) => {
const v = Math.sin(i * 127.1 + seed * 311.7) * 43758.5453;
return v - Math.floor(v);
};
export const isHe = (s: string) => /[-]/.test(s);
export const dirOf = (s: string): 'rtl' | 'ltr' => (isHe(s) ? 'rtl' : 'ltr');
/** "#a1b2c3" / "#abc" + alpha -> rgba() */
export const rgba = (hex: string, a = 1) => {
let h = hex.replace('#', '');
if (h.length === 3) h = h.split('').map((ch) => ch + ch).join('');
const n = parseInt(h.slice(0, 6), 16);
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
};
export const useT = () => {
const f = useCurrentFrame();
const {fps} = useVideoConfig();
return f / fps;
};
/** 12345 -> "12,345" */
export const fmt = (n: number, sep = ',') => Math.round(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, sep);
/** Arrive (scale up out of a blur) and leave (shrink into a blur) for a whole group. */
export const life = (t: number, total: number, inDur = 0.45, outDur = 0.35) => {
const a = p01(t, 0, inDur);
const z = inOut(p01(t, total - outDur, total));
return {
opacity: clamp01(a * 2.2) * (1 - z),
scale: lerp(0.82, 1, backOut(a, 1.4)) * (1 - 0.08 * z),
blur: (1 - outE(a)) * 12 + z * 10,
style: {} as React.CSSProperties,
};
};
export const lifeStyle = (t: number, total: number, inDur = 0.45, outDur = 0.35): React.CSSProperties => {
const l = life(t, total, inDur, outDur);
return {opacity: l.opacity, transform: `scale(${l.scale})`, filter: l.blur > 0.2 ? `blur(${l.blur}px)` : undefined};
};
/**
* Places a w x h box (design pixels of a 1080-high frame) on its side of the frame and scales it with the frame's
* short side and props.scale. Children are positioned inside the box.
*/
export const Stage: React.FC<{p: Base; w: number; h: number; children: React.ReactNode; style?: React.CSSProperties; defSide?: Side}> = ({p, w, h, children, style, defSide = 'center'}) => {
const {width: W, height: H} = useVideoConfig();
const u = (Math.min(W, H) / 1080) * (p.scale ?? 1);
const side = p.side ?? defSide;
const cx = ((p.x ?? (side === 'right' ? 73 : side === 'left' ? 27 : 50)) / 100) * W;
const cy = ((p.y ?? 50) / 100) * H;
return (
{children}
);
};
/** Soft two-layer drop shadow for cards on any footage */
export const SHADOW = '0 22px 48px rgba(12,12,20,0.22), 0 4px 12px rgba(12,12,20,0.14)';
/** The classic arrow cursor; (x, y) = its tip; press 0..1 = mouse down. */
export const Cursor: React.FC<{x: number; y: number; press?: number; size?: number; fill?: string; stroke?: string}> = ({x, y, press = 0, size = 1.6, fill = '#111', stroke = '#fff'}) => {
const s = size * (1 - 0.12 * press);
return (
);
};
/** A four-point sparkle centred on (0,0) in a -50..50 box. */
export const SPARK_PATH = 'M0 -50 C 6 -14 14 -6 50 0 C 14 6 6 14 0 50 C -6 14 -14 6 -50 0 C -14 -6 -6 -14 0 -50 Z';
export const Spark: React.FC<{x: number; y: number; size: number; color: string; rotate?: number; opacity?: number; glow?: boolean}> = ({x, y, size, color, rotate = 0, opacity = 1, glow = false}) => (
);
// ---------------------------------------------------------------- the scene
const hand = loadHand('normal', {weights: ['400'], subsets: ['hebrew', 'latin']});
/**
* Doodle arrow: a handwritten note writes itself, then a marker arrow with a loop draws from it towards the point
* of interest, the head flicks on, and the line "boils" a little like hand-drawn animation. Flip / rotate it to
* point anywhere. 3.4 s.
* Inspired by hand-drawn arrow packs (LottieFiles "Hand Draw Arrows") and "Animated Arrow" (Jitter), redesigned: a
* looped marker arrow with its own handwritten label and line boil.
*/
export type ArrowProps = Base & {
label: string;
color: string;
width: number; // stroke
flip: boolean; // mirror left / right
rotate: number; // degrees
boil: boolean;
};
export const arrowDefaults: ArrowProps = {
duration: 3.4,
side: 'right',
label: 'תסתכלו כאן!',
color: '#FF5A1F',
width: 13,
flip: false,
rotate: 0,
boil: true,
};
const W = 620;
const H = 520;
// tail top right, a loop in the middle, head bottom left (design pixels)
const PATH: [number, number][] = [
[470, 150], [430, 175], [380, 205], [330, 240], [300, 280], [305, 325], [345, 345], [380, 320], [372, 275], [330, 262],
[280, 280], [235, 320], [195, 365], [160, 410], [130, 450],
];
const smooth = (pts: [number, number][]) => {
let d = `M${pts[0][0]} ${pts[0][1]}`;
for (let i = 1; i < pts.length - 1; i++) {
const mx = (pts[i][0] + pts[i + 1][0]) / 2;
const my = (pts[i][1] + pts[i + 1][1]) / 2;
d += ` Q${pts[i][0]} ${pts[i][1]} ${mx} ${my}`;
}
const l = pts[pts.length - 1];
return `${d} L${l[0]} ${l[1]}`;
};
export const StkArrow: React.FC = (props) => {
const p = {...arrowDefaults, ...props};
const t = useT();
const total = p.duration!;
const f = Math.floor(t * 30 / 3); // boil changes every 3 frames
const j = (i: number, k: number) => (p.boil ? (rnd(i * 3 + k + f * 17, 5) - 0.5) * 3.2 : 0);
const pts = PATH.map(([x, y], i) => [x + j(i, 0), y + j(i, 1)] as [number, number]);
const d = smooth(pts);
const draw = inOut(p01(t, 0.75, 1.55));
const head = outE(p01(t, 1.5, 1.68));
const write = p01(t, 0.1, 0.85);
const out = p01(t, total - 0.45, total - 0.05);
const end = pts[pts.length - 1];
const prev = pts[pts.length - 3];
const ang = Math.atan2(end[1] - prev[1], end[0] - prev[0]);
const hl = 58 * head;
const h1 = [end[0] - Math.cos(ang - 0.55) * hl, end[1] - Math.sin(ang - 0.55) * hl];
const h2 = [end[0] - Math.cos(ang + 0.55) * hl, end[1] - Math.sin(ang + 0.55) * hl];
const label = p.label.slice(0, Math.ceil(p.label.length * write));
return (
{label}
);
};
export const meta = {id: 'LibStkArrow', component: StkArrow, defaults: arrowDefaults};