| name | tejas-captions |
| description | Phrase-chunk caption system for Tejas's personal video content — Geist card, 3-5 word chunks, muted→paper active word, clean non-overlapping exits. Use when building or editing captions for Tejas's talking-head or technical shorts. For AIS/client work, use the older caption recipe in /short-form-video or /hyperframes instead. |
Tejas · Phrase-Chunk Captions
A caption system for Tejas's personal-content videos. Encodes the warm-monochrome + shadcn-modern aesthetic. See MOTION_PHILOSOPHY.md §3.2 and the design spec §4 for the philosophy.
When to fire
- Any new composition under
video-projects/ authored in the Tejas aesthetic
- Rebuilding an existing project's
captions.html into the phrase-chunk model
- Re-syncing captions after audio edit (use the existing
shift() pattern from may-shorts-19)
DO NOT fire for AIS projects, may-shorts-* (other than future Tejas ones), clickup-demo, or client work. Those keep their existing karaoke styles.
The model
- Transcripts come word-timed from
npx hyperframes transcribe <audio>.mp4 --model small.en --json.
- Words are grouped into chunks of 3–5 words, hard max 5.
- Chunk breaks on: (a) punctuation
, . ? ! ; — punctuated word ends its chunk; (b) pauses ≥ 300ms between consecutive word end/start timestamps.
- One chunk visible at a time. The outgoing chunk's exit animation fully completes (including visibility-hide) BEFORE the incoming chunk's enter begins.
Visual locked
- Wrap:
position: absolute; left: 48px; right: 48px; bottom: 240px; display: flex; justify-content: center;
- Card:
background: rgba(10,10,9,0.78); backdrop-filter: blur(14px) saturate(1.1); border: 1px solid rgba(250,250,246,0.08); border-radius: 12px; padding: 12px 22px; box-shadow: 0 8px 32px rgba(0,0,0,0.5), inset 0 1px 0 rgba(250,250,246,0.06);
- Text:
font: 700 54px/1.15 "Geist", sans-serif; letter-spacing: -0.015em;
- Words start
color: var(--tejas-muted); (zinc-500). On their start: tween to var(--tejas-paper) + scale: 1.04 in 0.22s expo.out. On their end: scale back to 1 in 0.14s power2.out; color stays paper until chunk exit.
Timing
For chunk with first-word-start t0 and last-word-end t1:
- Fade-in at
fadeInAt = t0 - 0.14, duration 0.18s power2.out, y: 8→0, opacity: 0→1
- Per-word active/end tweens fire at each word's
start / end within the chunk
- Fade-out at
fadeOutAt = t1 + 0.12, duration 0.22s power2.in, y: 0→-6, opacity: 1→0
- Visibility hidden at
hideAt = fadeOutAt + FADE_OUT + 0.05 = t1 + 0.39
- Next chunk's fadeInAt MUST be ≥ previous chunk's hideAt. If not, either: (a) shorten the previous chunk by moving word boundaries, (b) shrink the POST_HOLD / PRE_ROLL values for that pair, or (c) merge the two chunks.
Keyword emphasis (inline)
Mark a word's kw: true in the transcript data. On that word's start:
- Its
color tween ends at var(--tejas-paper) with scale: 1.10 (not 1.04).
- A child
<span class="cap-kw-underline"> tweens transform: scaleX(0→1) in 0.18s expo.out, transform-origin: left center.
- On chunk exit, the underline fades with the chunk.
Reserved for one word per chunk max. Use sparingly — the payoff word only.
Canonical captions.html recipe
The complete reference implementation below is from video-projects/brain-energy/compositions/captions.html. When invoking this skill on a new project, adapt the CHUNKS array and COMP_DURATION constant to match the new transcript and clip duration; all styling, timing constants, and DOM-building logic stay the same.
<template id="captions-template">
<div
data-composition-id="captions"
data-start="0"
data-width="1080"
data-height="1920"
data-duration="37.17"
>
<div class="cap-stage" id="cap-stage"></div>
<style>
[data-composition-id="captions"] {
position: absolute;
inset: 0;
pointer-events: none;
}
[data-composition-id="captions"] .cap-stage {
position: absolute;
left: 48px;
right: 48px;
bottom: 240px;
height: 0;
pointer-events: none;
}
[data-composition-id="captions"] .cap-card {
position: absolute;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
opacity: 0;
visibility: hidden;
}
[data-composition-id="captions"] .cap-inner {
background: rgba(10, 10, 9, 0.78);
backdrop-filter: blur(14px) saturate(1.1);
-webkit-backdrop-filter: blur(14px) saturate(1.1);
border: 1px solid rgba(250, 250, 246, 0.08);
border-radius: 12px;
padding: 12px 22px;
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(250, 250, 246, 0.06);
font-family: "Geist", "Inter", sans-serif;
font-weight: 700;
font-size: 54px;
line-height: 1.15;
letter-spacing: -0.015em;
color: var(--tejas-muted);
max-width: 960px;
text-align: center;
white-space: normal;
}
[data-composition-id="captions"] .cap-word {
display: inline-block;
transform-origin: center center;
color: var(--tejas-muted);
will-change: transform, color;
position: relative;
}
[data-composition-id="captions"] .cap-kw-underline {
position: absolute;
left: 3%;
right: 3%;
bottom: -6px;
height: 3px;
background: var(--tejas-paper);
border-radius: 2px;
transform: scaleX(0);
transform-origin: left center;
will-change: transform;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
(function () {
const CHUNKS = [
{ words: [
{ w: "Your", s: 0.15, e: 0.25 },
{ w: "brain", s: 0.35, e: 0.56 },
{ w: "mogs", s: 0.56, e: 0.81, kw: true },
{ w: "every", s: 0.81, e: 1.12 },
{ w: "computer", s: 1.12, e: 2.04 }
]},
{ words: [
{ w: "in", s: 2.04, e: 2.12 },
{ w: "terms of", s: 2.18, e: 2.51 },
{ w: "energy", s: 2.51, e: 2.83 },
{ w: "efficiency.", s: 2.91, e: 3.36 }
]},
{ words: [
{ w: "Your", s: 3.51, e: 3.58 },
{ w: "brain", s: 3.58, e: 3.86 },
{ w: "runs at", s: 3.86, e: 4.19 },
{ w: "about", s: 4.19, e: 4.47 },
{ w: "20 watts.", s: 4.47, e: 5.12, kw: true }
]},
{ words: [
{ w: "That's", s: 5.27, e: 5.37 },
{ w: "like a", s: 5.47, e: 5.67 },
{ w: "dim", s: 5.67, e: 5.82 },
{ w: "light bulb,", s: 5.82, e: 6.41 }
]},
{ words: [
{ w: "even", s: 6.41, e: 6.59 },
{ w: "less than an", s: 6.61, e: 7.07 },
{ w: "iPhone", s: 7.07, e: 7.35 },
{ w: "charger.", s: 7.35, e: 7.84 }
]},
{ words: [
{ w: "To do what", s: 7.84, e: 8.25 },
{ w: "your brain", s: 8.25, e: 8.69 },
{ w: "does in a", s: 8.76, e: 9.09 },
{ w: "single", s: 9.09, e: 9.39 },
{ w: "conversation,", s: 9.39, e: 10.19 }
]},
{ words: [
{ w: "AI takes", s: 10.23, e: 10.65 },
{ w: "about", s: 10.65, e: 10.92 },
{ w: "20", s: 11.01, e: 11.36 },
{ w: "kilowatts.", s: 11.36, e: 12.16, kw: true }
]},
{ words: [
{ w: "But the", s: 12.16, e: 12.42 },
{ w: "thing that", s: 12.51, e: 12.91 },
{ w: "keeps researchers", s: 12.91, e: 13.71 },
{ w: "up", s: 13.71, e: 13.80 }
]},
{ words: [
{ w: "is that we", s: 13.89, e: 14.16 },
{ w: "don't actually", s: 14.16, e: 14.56 },
{ w: "know", s: 14.56, e: 14.72 },
{ w: "why.", s: 14.72, e: 14.96, kw: true }
]},
{ words: [
{ w: "The brain", s: 15.11, e: 15.42 },
{ w: "is as", s: 15.42, e: 15.63 },
{ w: "efficient", s: 15.63, e: 16.12 },
{ w: "as it is.", s: 16.22, e: 16.72 }
]},
{ words: [
{ w: "We just", s: 16.72, e: 16.98 },
{ w: "know it does", s: 17.07, e: 17.67 },
{ w: "something", s: 17.78, e: 18.24 },
{ w: "fundamentally", s: 18.24, e: 18.84 },
{ w: "different.", s: 18.90, e: 19.12 }
]},
{ words: [
{ w: "Than", s: 19.27, e: 19.30 },
{ w: "every other", s: 19.30, e: 19.76 },
{ w: "computer on the", s: 19.76, e: 20.28 },
{ w: "planet.", s: 20.38, e: 20.76 }
]},
{ words: [
{ w: "AI companies", s: 20.76, e: 21.51 },
{ w: "are spending", s: 21.51, e: 22.24 },
{ w: "billions of", s: 22.24, e: 22.82, kw: true },
{ w: "dollars", s: 22.82, e: 23.15 }
]},
{ words: [
{ w: "trying to", s: 23.19, e: 23.58 },
{ w: "bridge this", s: 23.58, e: 24.06 },
{ w: "gap.", s: 24.06, e: 24.24 }
]},
{ words: [
{ w: "But the", s: 24.24, e: 24.50 },
{ w: "answer to this", s: 24.59, e: 25.09 }
]},
{ words: [
{ w: "may actually", s: 25.16, e: 25.69 },
{ w: "lie in your", s: 25.69, e: 26.14 },
{ w: "skull.", s: 26.14, e: 26.56, kw: true }
]},
{ words: [
{ w: "I'm a", s: 26.63, e: 26.81 },
{ w: "CS student", s: 26.91, e: 27.51 },
{ w: "at", s: 27.62, e: 27.68 },
{ w: "CMU,", s: 27.68, e: 28.08 }
]},
{ words: [
{ w: "and I'm", s: 28.08, e: 28.34 },
{ w: "obsessed with", s: 28.41, e: 28.96 },
{ w: "this", s: 28.96, e: 29.16 },
{ w: "problem.", s: 29.16, e: 29.64 }
]},
{ words: [
{ w: "So I'm", s: 29.64, e: 29.94 },
{ w: "building a way", s: 30.03, e: 30.63 },
{ w: "to copy", s: 30.74, e: 30.98 },
{ w: "exactly", s: 30.98, e: 31.32 }
]},
{ words: [
{ w: "what the", s: 31.41, e: 31.79 },
{ w: "brain does,", s: 31.79, e: 32.40 }
]},
{ words: [
{ w: "and", s: 32.40, e: 32.51 },
{ w: "publishing", s: 32.59, e: 33.00 },
{ w: "all of it.", s: 33.15, e: 33.44 }
]},
{ words: [
{ w: "If you", s: 33.44, e: 33.64 },
{ w: "want to", s: 33.64, e: 33.88 },
{ w: "build something", s: 33.88, e: 34.39 }
]},
{ words: [
{ w: "along with me", s: 34.50, e: 34.88 },
{ w: "that helps", s: 34.88, e: 35.30 },
{ w: "save the", s: 35.30, e: 35.70 },
{ w: "planet,", s: 35.70, e: 36.18 }
]},
{ words: [
{ w: "follow", s: 36.18, e: 36.52 },
{ w: "along for", s: 36.52, e: 36.95 },
{ w: "more.", s: 36.95, e: 37.35 }
]}
];
const COMP_DURATION = 37.17;
const FADE_IN = 0.18;
const FADE_OUT = 0.22;
const PRE_ROLL = 0.14;
const POST_HOLD = 0.12;
const HIDE_BUFFER = 0.05;
const MUTED = "var(--tejas-muted)";
const PAPER = "var(--tejas-paper)";
const stage = document.querySelector('[data-composition-id="captions"] #cap-stage');
if (!stage) return;
CHUNKS.forEach(function (chunk, ci) {
const card = document.createElement("div");
card.className = "cap-card";
card.id = "cap-c-" + ci;
const inner = document.createElement("div");
inner.className = "cap-inner";
chunk.words.forEach(function (w, wi) {
const span = document.createElement("span");
span.className = "cap-word";
span.id = "cap-w-" + ci + "-" + wi;
span.textContent = w.w;
if (w.kw) {
const ul = document.createElement("span");
ul.className = "cap-kw-underline";
ul.id = "cap-ul-" + ci + "-" + wi;
span.appendChild(ul);
}
inner.appendChild(span);
if (wi < chunk.words.length - 1) {
inner.appendChild(document.createTextNode(" "));
}
});
card.appendChild(inner);
stage.appendChild(card);
});
const tl = gsap.timeline({ paused: true });
CHUNKS.forEach(function (chunk, ci) {
const t0 = chunk.words[0].s;
const t1 = chunk.words[chunk.words.length - 1].e;
const cardSel = '[data-composition-id="captions"] #cap-c-' + ci;
const fadeInAt = Math.max(t0 - PRE_ROLL, 0);
const fadeOutAt = t1 + POST_HOLD;
const hideAt = fadeOutAt + FADE_OUT + HIDE_BUFFER;
chunk.words.forEach(function (w, wi) {
const wordSel = '[data-composition-id="captions"] #cap-w-' + ci + "-" + wi;
tl.set(wordSel, { color: MUTED, scale: 1.0 }, fadeInAt);
if (w.kw) {
const ulSel = '[data-composition-id="captions"] #cap-ul-' + ci + "-" + wi;
tl.set(ulSel, { scaleX: 0 }, fadeInAt);
}
});
tl.set(cardSel, { visibility: "visible" }, fadeInAt);
tl.fromTo(
cardSel,
{ opacity: 0, y: 8 },
{ opacity: 1, y: 0, duration: FADE_IN, ease: "power2.out" },
fadeInAt
);
chunk.words.forEach(function (w, wi) {
const wordSel = '[data-composition-id="captions"] #cap-w-' + ci + "-" + wi;
const activeScale = w.kw ? 1.10 : 1.04;
tl.to(
wordSel,
{ color: PAPER, scale: activeScale, duration: 0.22, ease: "expo.out" },
w.s
);
tl.to(
wordSel,
{ scale: 1.0, duration: 0.14, ease: "power2.out" },
w.e
);
if (w.kw) {
const ulSel = '[data-composition-id="captions"] #cap-ul-' + ci + "-" + wi;
tl.to(ulSel, { scaleX: 1, duration: 0.18, ease: "expo.out" }, w.s);
}
});
tl.to(
cardSel,
{ opacity: 0, y: -6, duration: FADE_OUT, ease: "power2.in" },
fadeOutAt
);
tl.set(cardSel, { visibility: "hidden" }, hideAt);
});
tl.to({}, { duration: COMP_DURATION }, 0);
window.__timelines = window.__timelines || {};
window.__timelines["captions"] = tl;
})();
</script>
</div>
</template>
Checklist before moving on