Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
310 lines
9.6 KiB
TypeScript
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));
|
|
},
|
|
};
|