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)); }, };