Captions on a Beat
The code of the Captions on a Beat demo, 4 files.
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Captions on a Beat | WildflowerJS Demo</title>
<script defer src="/js/analytics.js"></script>
<!-- The framework's full tier (the one with wildflower.query), then Kinetic
with its preview bar, then the HyperFrames runtime for the
HyperFrames render, then the scene. The page runs without the
runtime. With it, the runtime sends the seeks; it starts as soon as
it loads and waits for Kinetic, so Kinetic's tag comes first. -->
<script defer src="/js/dist/wildflower.full.min.js"></script>
<script defer src="/js/extensions/kinetic.wf.min.js" data-preview></script>
<script defer src="https://cdn.jsdelivr.net/npm/@hyperframes/core@0.8.133/dist/hyperframe.runtime.iife.js"></script>
<script defer src="app.js"></script>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; background: #0a0b10; color: #e6e6f0; font-family: system-ui, sans-serif; }
.demo-toolbar { display: flex; justify-content: space-between; align-items: center; padding: 8px 20px; background: #12141c; border-bottom: 1px solid rgba(255,255,255,0.08); }
.demo-toolbar a { display: flex; align-items: center; gap: 10px; text-decoration: none; color: #5fa6a6; font-size: 1rem; }
.demo-toolbar .logo { height: 32px; }
.demo-toolbar .brand-wf { font-weight: 520; }
.demo-toolbar .brand-js { font-weight: 700; }
.demo-toolbar .demo-info { font-size: 11px; color: rgba(255,255,255,0.6); text-align: right; }
.demo-toolbar .demo-info strong { color: rgba(255,255,255,0.9); }
/* Hidden until Kinetic marks the root ready: before that, the stage
holds unbound markup. With data-preview, Kinetic scales the stage
down to the window. */
#stage:not([data-kinetic-ready]) { visibility: hidden; }
.frame { padding: 24px; }
#stage { position: relative; width: 1280px; height: 720px; overflow: hidden; background: #0d0b16; }
.layer { position: absolute; inset: 0; }
.glow { position: absolute; left: 50%; top: 42%; width: 420px; height: 420px; margin: -210px 0 0 -210px; border-radius: 50%;
background: radial-gradient(circle, rgba(157,123,255,0.55), rgba(157,123,255,0.08) 55%, transparent 70%); will-change: transform; }
.disc { position: absolute; left: 50%; top: 42%; width: 180px; height: 180px; margin: -90px 0 0 -90px; border-radius: 50%; background: #9d7bff; will-change: transform; }
.bars { position: absolute; left: 50%; top: 42%; width: 0; height: 0; }
.bar { position: absolute; left: -4px; top: -4px; width: 8px; border-radius: 4px; background: #c9b8ff; transform-origin: 50% 0; }
.caption { position: absolute; left: 80px; right: 80px; bottom: 96px; display: flex; flex-wrap: wrap; justify-content: center; gap: 0 18px; min-height: 96px; }
.word { font-size: 62px; font-weight: 700; line-height: 1.2; color: #fff; will-change: transform, opacity; }
.meter { position: absolute; left: 80px; right: 80px; bottom: 56px; height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; }
.meter i { display: block; height: 100%; border-radius: 2px; background: #9d7bff; }
.where { position: absolute; left: 80px; top: 48px; font-size: 18px; color: #7d84a8; font-variant-numeric: tabular-nums; }
.about { max-width: 1280px; margin: 0 auto; padding: 0 24px 96px; color: #a9adc2; font-size: 14px; line-height: 1.55; }
.about p { margin: 0 0 10px; }
.about b { color: #e6e6f0; font-weight: 600; }
.about code { color: #e6e6f0; font-size: 13px; }
</style>
</head>
<body>
<div class="demo-toolbar">
<a href="/demos/">
<img class="logo" src="/assets/wildflower_96.png" alt="WildflowerJS">
<span><span class="brand-wf">Wildflower</span><span class="brand-js">JS</span> Demos</span>
</a>
<div class="demo-info"><strong>Captions on a Beat</strong>: captions and a pulse</div>
</div>
<div class="frame">
<div id="stage" data-composition-id="captions" data-no-timeline data-start="0" data-duration="13" data-width="1280" data-height="720" data-fps="30">
<div class="layer" data-component="beat-scene" data-start="0" data-duration="13">
<div class="layer" data-bind-style="card">
<div class="glow" data-bind-style="glow"></div>
<div class="bars" data-list="bars" data-key="key"><template><i class="bar" data-bind-style="style"></i></template></div>
<div class="disc" data-bind-style="disc"></div>
<div class="where" data-bind="where"></div>
<p class="caption" data-list="lineWords" data-key="key"><template><span class="word" data-bind="text" data-bind-style="style"></span></template></p>
<div class="meter"><i data-bind-style="meter"></i></div>
</div>
</div>
</div>
</div>
<div class="about">
<p>The captions and the pulse come from the transcript and beat file HyperFrames writes for a voice track. The page has no audio.</p>
</div>
</body>
</html>
app.js
// This scene drives a caption line and a pulse from a transcript and a beat
// file, the two files HyperFrames writes for a voice track. Every frame is
// a function of the composition time. It loads the full tier because it
// uses two queries. In a render, or with ?embed, Kinetic shows the stage
// alone.
const { scene, captions, beat, noise, spring, interpolate, styleAt } = wildflower.kinetic;
// ---- The data --------------------------------------------------------------
wildflower.query('words', { from: 'data/transcript.json' });
wildflower.query('beats', { from: 'data/beats.json', select: (file) => file.beats });
const BARS = 36;
const SEED = 11;
// ---- The scene ---------------------------------------------------------------
scene('beat-scene', {
waitFor: ['words', 'beats'],
edges: { enter: 0.4, exit: 0.6 },
computed: {
// Captions and beats are both read at the composition time.
pulse() { return beat(this.time, wildflower.getQuery('beats').rows); },
line() { return captions(this.time, wildflower.getQuery('words').rows, { maxWords: 4, hold: 0.5 }); },
// A word fades in with a spring when it starts, begins 10 px high,
// and eases to rest by the time it ends. Words not yet spoken stay
// at 30% opacity.
lineWords() {
return this.line.words.map((w) => {
const rise = w.state === 'future' ? 0 : spring(this.time - w.start, { damping: 14 });
const lift = w.state === 'active' ? 1 - w.p : 0;
return {
key: w.key,
text: w.text,
style: {
...styleAt(this.time, {
opacity: 0.3 + 0.7 * Math.min(1, rise),
translateY: -10 * lift,
scale: 1 + 0.08 * lift * rise,
}),
color: w.state === 'active' ? '#c9b8ff' : '#fff',
},
};
});
},
disc() { return styleAt(this.time, { scale: 1 + 0.14 * this.pulse }); },
glow() { return styleAt(this.time, { scale: 1 + 0.35 * this.pulse, opacity: 0.6 + 0.4 * this.pulse }); },
// A ring of bars around the disc; each bar's height comes from
// noise() at its own offset, scaled by the beat.
bars() {
const t = this.time;
const out = [];
for (let i = 0; i < BARS; i++) {
const angle = (360 / BARS) * i;
const n = 0.5 + 0.5 * noise(i * 0.61 + t * 1.7, SEED);
const h = 18 + 70 * n * (0.35 + 0.65 * this.pulse);
out.push({ key: i, style: { height: h.toFixed(1) + 'px', transform: 'rotate(' + angle + 'deg) translateY(112px)' } });
}
return out;
},
where() {
const line = this.line;
return line.empty ? '' : 'line ' + (line.index + 1) + ', ' + this.time.toFixed(2) + ' s';
},
meter() { return { width: (100 * this.progress).toFixed(2) + '%' }; },
card() { return { opacity: String(this.presence) }; },
},
});
data/beats.json
{
"version": 1,
"audio": "voice.mp3",
"beats": [
{ "time": 0.0, "strength": 1.0 }, { "time": 0.5, "strength": 0.45 }, { "time": 1.0, "strength": 0.8 }, { "time": 1.5, "strength": 0.45 },
{ "time": 2.0, "strength": 1.0 }, { "time": 2.5, "strength": 0.45 }, { "time": 3.0, "strength": 0.8 }, { "time": 3.5, "strength": 0.45 },
{ "time": 4.0, "strength": 1.0 }, { "time": 4.5, "strength": 0.45 }, { "time": 5.0, "strength": 0.8 }, { "time": 5.5, "strength": 0.45 },
{ "time": 6.0, "strength": 1.0 }, { "time": 6.5, "strength": 0.45 }, { "time": 7.0, "strength": 0.8 }, { "time": 7.5, "strength": 0.45 },
{ "time": 8.0, "strength": 1.0 }, { "time": 8.5, "strength": 0.45 }, { "time": 9.0, "strength": 0.8 }, { "time": 9.5, "strength": 0.45 },
{ "time": 10.0, "strength": 1.0 }, { "time": 10.5, "strength": 0.45 }, { "time": 11.0, "strength": 0.8 }, { "time": 11.5, "strength": 0.45 },
{ "time": 12.0, "strength": 1.0 }, { "time": 12.5, "strength": 0.45 }, { "time": 13.0, "strength": 0.6 }
]
}
data/transcript.json
[
{ "id": "w0", "text": "Every", "start": 0.60, "end": 0.92 },
{ "id": "w1", "text": "frame", "start": 0.92, "end": 1.30 },
{ "id": "w2", "text": "is", "start": 1.30, "end": 1.46 },
{ "id": "w3", "text": "a", "start": 1.46, "end": 1.58 },
{ "id": "w4", "text": "function", "start": 1.58, "end": 2.10 },
{ "id": "w5", "text": "of", "start": 2.10, "end": 2.26 },
{ "id": "w6", "text": "time.", "start": 2.26, "end": 2.90 },
{ "id": "w7", "text": "Seek", "start": 3.60, "end": 3.98 },
{ "id": "w8", "text": "anywhere", "start": 3.98, "end": 4.60 },
{ "id": "w9", "text": "and", "start": 4.60, "end": 4.78 },
{ "id": "w10", "text": "the", "start": 4.78, "end": 4.92 },
{ "id": "w11", "text": "words", "start": 4.92, "end": 5.34 },
{ "id": "w12", "text": "are", "start": 5.34, "end": 5.52 },
{ "id": "w13", "text": "already", "start": 5.52, "end": 6.02 },
{ "id": "w14", "text": "there.", "start": 6.02, "end": 6.60 },
{ "id": "w15", "text": "No", "start": 7.20, "end": 7.46 },
{ "id": "w16", "text": "timeline", "start": 7.46, "end": 8.04 },
{ "id": "w17", "text": "to", "start": 8.04, "end": 8.20 },
{ "id": "w18", "text": "register.", "start": 8.20, "end": 8.86 },
{ "id": "w19", "text": "Nothing", "start": 9.40, "end": 9.84 },
{ "id": "w20", "text": "to", "start": 9.84, "end": 9.98 },
{ "id": "w21", "text": "keep", "start": 9.98, "end": 10.32 },
{ "id": "w22", "text": "in", "start": 10.32, "end": 10.48 },
{ "id": "w23", "text": "sync.", "start": 10.48, "end": 11.16 }
]