text.ts
examples/film/text.ts · raw · all examples · site home
Exports: EntranceCaptionSpecCaptions
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122
// Timed type over the render: each caption is an element shown from `at`
// to `until` (film seconds) with an entrance and an exit computed from the
// playhead, so a frame looks the same however it was reached.
import { clamp01, easeOut, smooth } from "./timeline.js";
export type Entrance = "letters" | "rise" | "fade" | "type" | "slam" | "wipe";
export interface CaptionSpec {
at: number;
until: number;
html: string;
className?: string;
anim?: Entrance;
// Seconds between letters (letters, type) and of the entrance / exit
stagger?: number;
enter?: number;
exit?: number;
}
interface Caption extends Required<CaptionSpec> {
element: HTMLElement;
chars: HTMLElement[];
}
// Wraps every character of the element's text in a span (words kept whole
// so lines break between them)
function splitChars(element: HTMLElement) {
const chars: HTMLElement[] = [];
const walk = (node: Node) => {
for (const child of [...node.childNodes]) {
if (child.nodeType === Node.TEXT_NODE) {
const fragment = document.createDocumentFragment();
for (const word of (child.textContent ?? "").split(/(\s+)/)) {
if (word === "") {
continue;
}
if (/^\s+$/.test(word)) {
fragment.append(word);
continue;
}
const w = document.createElement("span");
w.className = "w";
for (const c of word) {
const span = document.createElement("span");
span.className = "c";
span.textContent = c;
chars.push(span);
w.append(span);
}
fragment.append(w);
}
child.replaceWith(fragment);
} else if (child.nodeType === Node.ELEMENT_NODE && !(child as HTMLElement).classList.contains("keep")) {
walk(child);
}
}
};
walk(element);
return chars;
}
export class Captions {
private list: Caption[] = [];
private root: HTMLElement;
constructor(root: HTMLElement) {
this.root = root;
}
add(spec: CaptionSpec) {
const element = document.createElement("div");
element.className = `cap ${spec.className ?? ""}`;
element.innerHTML = spec.html;
element.style.visibility = "hidden";
this.root.append(element);
const anim = spec.anim ?? "letters";
const chars = anim === "letters" || anim === "type" ? splitChars(element) : [];
const caption: Caption = { stagger: anim === "type" ? 0.028 : 0.022, enter: 0.7, exit: 0.45, className: "", anim, ...spec, element, chars };
this.list.push(caption);
return element;
}
update(t: number) {
for (const c of this.list) {
const on = t >= c.at && t <= c.until;
c.element.style.visibility = on ? "visible" : "hidden";
if (!on) {
continue;
}
const local = t - c.at;
const out = clamp01((t - (c.until - c.exit)) / c.exit);
const style = c.element.style;
if (c.anim === "letters") {
c.chars.forEach((char, i) => {
const p = easeOut((local - i * c.stagger) / c.enter);
const q = smooth((t - (c.until - c.exit) - i * c.stagger * 0.4) / c.exit);
char.style.opacity = String(p * (1 - q));
char.style.filter = `blur(${((1 - p) * 0.35 + q * 0.25).toFixed(3)}em)`;
char.style.transform = `translateY(${((1 - p) * 0.35 - q * 0.15).toFixed(3)}em)`;
});
} else if (c.anim === "type") {
const shown = Math.floor(local / c.stagger);
c.chars.forEach((char, i) => (char.style.opacity = i < shown ? "1" : "0"));
style.opacity = String(1 - out);
} else {
const p = easeOut(local / c.enter);
const q = smooth(out);
style.opacity = String(Math.min(p, 1 - q));
if (c.anim === "rise") {
style.transform = `translateY(${((1 - p) * 0.6 - q * 0.3).toFixed(3)}em)`;
style.filter = `blur(${((1 - p) * 0.3 + q * 0.2).toFixed(3)}em)`;
} else if (c.anim === "slam") {
style.transform = `scale(${(1 + (1 - p) * 0.5 + local * 0.012).toFixed(4)})`;
style.filter = `blur(${((1 - p) * 0.25 + q * 0.3).toFixed(3)}em)`;
style.letterSpacing = "";
} else if (c.anim === "wipe") {
style.clipPath = `inset(0 ${((1 - p) * 100).toFixed(2)}% 0 0)`;
}
}
}
}
}