2026-10-03 17:47:24 +00:00

310 lines
9.6 KiB
TypeScript

import {
clamp,
easeInOutCubic,
easeOutBack,
easeOutCubic,
easeOutExpo,
fadeEnvelope,
hash01,
lerp,
progress,
} from "../easing.js";
import type { MotionFrameContext, MotionTemplate } from "../template.js";
import {
el,
escapeXml,
fitText,
fontStack,
fullBackground,
logoEl,
num,
stringParam,
svgDocument,
textEl,
unit,
} from "../template.js";
const INTRO_PARAMS = [
{ name: "title", type: "string", required: false },
{ name: "tagline", type: "string", required: false },
] as const;
function title(ctx: MotionFrameContext): string {
return stringParam(ctx, "title") ?? ctx.kit.name;
}
/** Fades the whole intro, background included, so it hands over to the video. */
function outro(t: number, ctx: MotionFrameContext): number {
return fadeEnvelope(t, ctx.durationMs, 0, Math.min(400, ctx.durationMs * 0.15));
}
function tagline(ctx: MotionFrameContext, t: number, y: number, startPct: number): string {
const text = stringParam(ctx, "tagline");
if (!text) return "";
const appear = easeOutCubic(
progress(t, ctx.durationMs * startPct, ctx.durationMs * (startPct + 0.2)),
);
const fitted = fitText(text, ctx.width * 0.8, unit(ctx) * 0.055, unit(ctx) * 0.03);
return textEl(fitted.text, {
x: ctx.width / 2,
y: y + (1 - appear) * unit(ctx) * 0.03,
"text-anchor": "middle",
"font-family": fontStack(ctx.fonts.body, "sans-serif"),
"font-size": fitted.size,
fill: ctx.kit.colors.text,
opacity: appear,
});
}
export const introLogoReveal: MotionTemplate = {
info: {
id: "intro_logo_reveal",
kind: "intro",
name: "Logo reveal",
minDurationMs: 1500,
maxDurationMs: 4000,
params: [...INTRO_PARAMS],
},
frame(t, ctx) {
const u = unit(ctx);
const cx = ctx.width / 2;
const cy = ctx.height * 0.46;
const d = ctx.durationMs;
const circle = easeOutExpo(progress(t, 0, d * 0.3));
const pop = progress(t, d * 0.18, d * 0.48);
const scale = pop <= 0 ? 0 : easeOutBack(pop);
const radius = u * 0.24;
let mark: string;
if (ctx.logos.length > 0) {
const size = radius * 1.3 * scale;
mark = logoEl(ctx, { x: cx - size / 2, y: cy - size / 2, w: size, h: size });
} else {
const fitted = fitText(title(ctx), ctx.width * 0.8, u * 0.14, u * 0.05);
mark = textEl(fitted.text, {
x: cx,
y: cy,
"text-anchor": "middle",
"dominant-baseline": "central",
"font-family": fontStack(ctx.fonts.heading, "sans-serif"),
"font-weight": 700,
"font-size": fitted.size * Math.max(scale, 0.001),
fill: ctx.kit.colors.text,
});
}
const ring = el("circle", {
cx,
cy,
r: Math.max(radius * circle, 0.01),
fill: ctx.logos.length > 0 ? ctx.kit.colors.primary : "none",
stroke: ctx.kit.colors.accent,
"stroke-width": u * 0.01,
opacity: ctx.logos.length > 0 ? 1 : 0.9,
});
const body = fullBackground(ctx) + ring + mark + tagline(ctx, t, cy + radius + u * 0.12, 0.42);
return svgDocument(ctx, el("g", { opacity: outro(t, ctx) }, body));
},
};
export const introKineticType: MotionTemplate = {
info: {
id: "intro_kinetic_type",
kind: "intro",
name: "Kinetic type",
minDurationMs: 1500,
maxDurationMs: 4000,
params: [...INTRO_PARAMS],
},
frame(t, ctx) {
const u = unit(ctx);
const d = ctx.durationMs;
const fitted = fitText(title(ctx), ctx.width * 0.86, u * 0.16, u * 0.05);
const words = fitted.text.split(/\s+/).filter(Boolean).slice(0, 12);
const stagger = (d * 0.35) / Math.max(words.length, 1);
const baseY = ctx.height * 0.48;
let previousOffset = 0;
const spans = words.map((word, index) => {
const appear = easeOutExpo(progress(t, index * stagger, index * stagger + d * 0.25));
const offset = (1 - appear) * u * 0.18;
const dy = offset - previousOffset;
previousOffset = offset;
const last = index === words.length - 1 && words.length > 1;
return el(
"tspan",
{
dy,
fill: last ? ctx.kit.colors.primary : ctx.kit.colors.text,
"fill-opacity": clamp(appear * 1.4),
},
escapeXml(index === words.length - 1 ? word : `${word} `),
);
});
const heading = el(
"text",
{
x: ctx.width / 2,
y: baseY,
"text-anchor": "middle",
"font-family": fontStack(ctx.fonts.heading, "sans-serif"),
"font-weight": 800,
"font-size": fitted.size,
},
spans.join("") || " ",
);
const barGrow = easeInOutCubic(progress(t, d * 0.3, d * 0.6));
const barWidth = ctx.width * 0.3 * barGrow;
const bar = el("rect", {
x: ctx.width / 2 - barWidth / 2,
y: baseY + fitted.size * 0.3,
width: barWidth,
height: u * 0.012,
fill: ctx.kit.colors.accent,
});
const body = fullBackground(ctx) + heading + bar + tagline(ctx, t, baseY + u * 0.16, 0.45);
return svgDocument(ctx, el("g", { opacity: outro(t, ctx) }, body));
},
};
/**
* Glitch bursts are scheduled at a fixed 2 Hz. Photosensitivity guidance
* (WCAG 2.3.1) allows at most three flashes per second; the template never
* changes full-frame brightness, and the bursts stay below that rate even if
* they were counted as flashes.
*/
export const GLITCH_BURST_INTERVAL_MS = 500;
const GLITCH_BURST_MS = 120;
const GLITCH_SLICES = 6;
export const introGlitch: MotionTemplate = {
info: {
id: "intro_glitch",
kind: "intro",
name: "Glitch",
minDurationMs: 1500,
maxDurationMs: 4000,
params: [...INTRO_PARAMS],
},
frame(t, ctx) {
const u = unit(ctx);
const d = ctx.durationMs;
const fitted = fitText(title(ctx).toUpperCase(), ctx.width * 0.84, u * 0.15, u * 0.05);
const cy = ctx.height * 0.47;
const burstIndex = Math.floor((t - 200) / GLITCH_BURST_INTERVAL_MS);
const inBurst =
t >= 200 && t - 200 - burstIndex * GLITCH_BURST_INTERVAL_MS < GLITCH_BURST_MS && t < d * 0.85;
const reveal = easeOutCubic(progress(t, 0, d * 0.25));
const textAttrs = {
x: ctx.width / 2,
y: cy,
"text-anchor": "middle",
"dominant-baseline": "central",
"font-family": fontStack(ctx.fonts.heading, "sans-serif"),
"font-weight": 900,
"font-size": fitted.size,
};
const bandTop = cy - fitted.size * 0.7;
const bandHeight = fitted.size * 1.4;
const sliceHeight = bandHeight / GLITCH_SLICES;
const defs = [
el(
"clipPath",
{ id: "reveal" },
el("rect", { x: 0, y: 0, width: ctx.width * reveal, height: ctx.height }),
),
...Array.from({ length: GLITCH_SLICES }, (_, index) =>
el(
"clipPath",
{ id: `slice${index}` },
el("rect", {
x: 0,
y: bandTop + index * sliceHeight,
width: ctx.width,
height: sliceHeight,
}),
),
),
].join("");
let title_: string;
if (inBurst) {
const split = u * 0.012;
const ghosts =
textEl(fitted.text, {
...textAttrs,
x: ctx.width / 2 - split,
fill: ctx.kit.colors.primary,
opacity: 0.8,
}) +
textEl(fitted.text, {
...textAttrs,
x: ctx.width / 2 + split,
fill: ctx.kit.colors.accent,
opacity: 0.8,
});
const slices = Array.from({ length: GLITCH_SLICES }, (_, index) => {
const shift = (hash01(burstIndex, index) - 0.5) * u * 0.08;
return el(
"g",
{ "clip-path": `url(#slice${index})`, transform: `translate(${num(shift)} 0)` },
textEl(fitted.text, { ...textAttrs, fill: ctx.kit.colors.text }),
);
}).join("");
title_ = ghosts + slices;
} else {
title_ = textEl(fitted.text, { ...textAttrs, fill: ctx.kit.colors.text });
}
const scan = el("rect", {
x: 0,
y: lerp(bandTop, bandTop + bandHeight, (t % 900) / 900),
width: ctx.width * reveal,
height: u * 0.004,
fill: ctx.kit.colors.accent,
opacity: 0.6,
});
const body =
fullBackground(ctx) +
el("g", { "clip-path": "url(#reveal)" }, title_ + scan) +
tagline(ctx, t, cy + fitted.size * 0.7 + u * 0.08, 0.4);
return svgDocument(ctx, el("g", { opacity: outro(t, ctx) }, body), defs);
},
};
export const introElegantFade: MotionTemplate = {
info: {
id: "intro_elegant_fade",
kind: "intro",
name: "Elegant fade",
minDurationMs: 1500,
maxDurationMs: 4000,
params: [...INTRO_PARAMS],
},
frame(t, ctx) {
const u = unit(ctx);
const d = ctx.durationMs;
const cy = ctx.height * 0.46;
const line = easeInOutCubic(progress(t, 0, d * 0.4));
const appear = easeOutCubic(progress(t, d * 0.15, d * 0.6));
const spacing = lerp(0.4, 0.15, appear);
const fitted = fitText(title(ctx), ctx.width * 0.8, u * 0.11, u * 0.04, spacing);
const heading = textEl(fitted.text, {
x: ctx.width / 2,
y: cy - u * 0.03 + (1 - appear) * u * 0.02,
"text-anchor": "middle",
"font-family": fontStack(ctx.fonts.heading, "serif"),
"font-weight": 400,
"font-size": fitted.size,
"letter-spacing": `${num(spacing)}em`,
fill: ctx.kit.colors.text,
opacity: appear,
});
const lineWidth = ctx.width * 0.4 * line;
const rule = el("rect", {
x: ctx.width / 2 - lineWidth / 2,
y: cy + u * 0.02,
width: lineWidth,
height: Math.max(1, u * 0.003),
fill: ctx.kit.colors.accent,
});
const body = fullBackground(ctx) + heading + rule + tagline(ctx, t, cy + u * 0.12, 0.5);
return svgDocument(ctx, el("g", { opacity: outro(t, ctx) }, body));
},
};