Setting the file. One moment. Messaging Multi Phrase · Hyperframes Animation · heygen-com/hyperframes · Skills DocsBlueprints Index
(opens in a new tab)
examples/messaging-multi-phrase.html
HTML·352 lines·12 KB
8 <!--
9 Blueprint: messaging-multi-phrase (HyperFrames preview)
10 HyperFrames-native version of the scene-9-multi-phrase concept.
11
12 Choreography (3 phrases, ~7.5 seconds total, content-driven duration):
13
14 Phrase 1 [0.00 – 3.86 s]
15 "With an AI assistant " (21 chars × 0.083 s = 1.75 s)
16 + "you can trust" (13 chars × 0.083 s = 1.08 s, accent pink)
17 + hold 1.00 s
18 → total 1.75 + 1.08 + 1.00 ≈ 3.83 s (computed by JS)
19
20 Phrase 2 [3.86 – 5.27 s]
21 "Instant " (8 chars × 0.083 s = 0.67 s)
22 + "insights" (8 chars × 0.083 s = 0.67 s, accent pink)
23 + hold 1.00 s
24 → total 0.67 + 0.67 + 1.00 ≈ 2.33 s, but starts at 3.86 → endTime ≈ 6.18 s
25
26 Phrase 3 [6.18 – 9.45 s]
27 "Endless " (8 chars × 0.083 = 0.67 s)
28 + "possibilities" (13 chars × 0.083 = 1.08 s, accent pink)
29 + hold 2.00 s (longer — closing beat)
30 → total 0.67 + 1.08 + 2.00 ≈ 3.75 s, but starts at 6.18 → endTime ≈ 9.93 s
31
32 Continuous: cursor blink (square wave via `tl.time() % 1.0`)
33 color, cursor blink. No per-phrase tweens.
34 - No conditional DOM — phrase container exists from t=0 with empty text;
35 onUpdate overwrites textContent in-place at each frame.
36 - TIMELINE is a plain const computed once at script load (no useMemo).
37 - Frames become seconds: charSpeed 2.5 frames → 0.083 s; hold 30 frames → 1.0 s.
38 - Cursor blink: `(t % BLINK_CYCLE) < BLINK_CYCLE / 2 ? 1 : 0` inside the same
39 onUpdate, not a separate component with `useCurrentFrame`.
40 - textContent !== visible guard skips redundant DOM writes during hold windows.
41 -->
42
43 <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
44
45 <style>
46 :root {
47 --text-primary: #ffffff;
48 --text-accent: #32fff6;
49 --text-accent-2: #a3ff7a;
50 --bg: linear-gradient(
51 135deg,
52 #3a3a3a 0%,
53 #17211f 30%,
54 #0b2328 48%,
55 #1f3518 70%,
56 #343434 100%
57 );
58 }
59 * {
60 margin: 0;
61 padding: 0;
62 box-sizing: border-box;
63 }
64 html,
65 body {
66 margin: 0;
67 width: 1920px;
68 height: 1080px;
69 overflow: hidden;
70 background: var(--bg);
71 font-family:
72 "Inter",
73 -apple-system,
74 BlinkMacSystemFont,
75 "Segoe UI",
76 Roboto,
77 sans-serif;
78 color: var(--text-primary);
79 -webkit-font-smoothing: antialiased;
80 text-rendering: geometricPrecision;
81 }
82
83 .stage {
84 position: absolute;
85 inset: 0;
86 display: flex;
87 align-items: center;
88 justify-content: center;
89 }
90
91 /* ============================================================
92 PHRASE STAGE — one shared line with three inline children:
93 .phrase-main primary-color portion
94 .phrase-accent accent-color portion
95 .phrase-cursor block cursor (color overridden per-frame)
96 `white-space: pre` keeps the trailing space in textMain so the
97 accent doesn't merge with the lead-in.
98 ============================================================ */
99 .phrase-stage {
100 position: absolute;
101 inset: 0;
102 width: 100%;
103 display: flex;
104 align-items: center;
105 justify-content: center;
106 font-size: 150px;
107 font-weight: 800;
108 line-height: 1;
109 letter-spacing: 0;
110 white-space: pre;
111 text-align: center;
112 text-shadow: 0 0 10px rgba(255, 255, 255, 0.16);
113 }
114 .phrase-main {
115 color: var(--text-primary);
116 }
117 .phrase-accent {
118 color: var(--text-accent);
119 text-shadow: 0 0 12px rgba(50, 255, 246, 0.28);
120 }
121 .phrase-cursor {
122 display: inline-block;
123 flex: 0 0 auto;
124 width: 8px;
125 height: 162px; /* ≈ 1.08 × fontSize */
126 background: var(--text-primary);
127 margin-left: 12px;
128 vertical-align: middle;
129 transform: translateY(10px); /* baseline alignment */
130 will-change: opacity, background-color;
131 box-shadow: 0 0 12px rgba(50, 255, 246, 0.36);
132 }
133
134 /* ============================================================
135 BACKGROUND TINT — subtle radial glow so pure black doesn't
136 feel sterile. Drops to ~rgb(8,8,12) at corners.
137 ============================================================ */
138 .bg-tint {
139 position: absolute;
140 inset: 0;
141 background:
142 radial-gradient(ellipse at 42% 48%, rgba(24, 217, 232, 0.32), transparent 42%),
143 radial-gradient(ellipse at 58% 52%, rgba(123, 234, 90, 0.26), transparent 44%),
144 radial-gradient(ellipse at 50% 50%, rgba(8, 16, 18, 0.42), transparent 70%);
145 pointer-events: none;
146 }
147 </style>
148 </head>
149 <body>
150 <div
151 id="root"
152 data-composition-id="main"
153 data-start="0"
154 data-duration="7.5"
155 data-width="1920"
156 data-height="1080"
157 >
158 <div
159 id="multi-phrase-scene"
160 class="stage clip"
161 data-start="0"
162 data-duration="7.5"
163 data-track-index="1"
164 >
165 <div class="bg-tint"></div>
166 <div class="phrase-stage">
167 <span class="phrase-main"></span><span class="phrase-accent"></span
168 ><span class="phrase-cursor"></span>
169 </div>
170 </div>
171 </div>
172
173 <script>
174 window.__timelines = window.__timelines || {};
175 const tl = gsap.timeline({ paused: true });
176
177 /* ============================================================
178 SCRIPT — content + per-phrase timing config.
179 Change SCRIPT and the timeline reshapes itself.
180 ============================================================ */
181 const SCRIPT = [
182 {
183 textMain: "Build video with ",
184 textAccent: "HTML",
185 charSpeed: 0.083, // seconds per character (= 2.5 frames @ 30 fps)
186 hold: 1.0,
187 },
188 {
189 textMain: "Seek ",
190 textAccent: "any frame",
191 charSpeed: 0.083,
192 hold: 1.0,
193 },
194 {
195 textMain: "Render to ",
196 textAccent: "MP4",
197 charSpeed: 0.083,
198 hold: 2.0, // longer closing beat
199 },
200 ];
201
202 /* ============================================================
203 TIMELINE — flat reduce, computed once.
204 Each entry gains absolute startTime + endTime in seconds.
205 ============================================================ */
206 let acc = 0;
207 const TIMELINE = SCRIPT.map((item) => {
208 const totalChars = item.textMain.length + item.textAccent.length;
209 const typingDuration = totalChars * item.charSpeed;
210 const totalDuration = typingDuration + item.hold;
211 const start = acc;
212 const end = start + totalDuration;
213 acc = end;
214 return Object.assign({}, item, {
215 startTime: start,
216 endTime: end,
217 typingDuration,
218 });
219 });
220 const TOTAL = TIMELINE[TIMELINE.length - 1].endTime;
221 // TOTAL ≈ 9.93s with the SCRIPT above. The composition root caps at 7.5s
222 // (data-duration), which is the actual render window — phrases 1+2 fit
223 // comfortably; phrase 3 holds long enough that any time after ~6.18s is
224 // already on the closing line. Adjust SCRIPT durations to fit your budget.
225
226 /* ============================================================
227 COLORS & BLINK
228 ============================================================ */
229 const MAIN_COLOR = "#FFFFFF";
230 const ACCENT_COLOR = "#32FFF6";
231 const BLINK_CYCLE = 1.0; // seconds — 0.5s on, 0.5s off (matches 30-frame square wave)
232
233 const mainEl = document.querySelector(".phrase-main");
234 const accentEl = document.querySelector(".phrase-accent");
235 const cursorEl = document.querySelector(".phrase-cursor");
236
237 /* ============================================================
238 MASTER ENGINE — single onUpdate writes text + cursor every frame.
239 ============================================================ */
240 // Cache the most-recently-active phrase index for seek-safe scans.
241 let lastIdx = 0;
242
243 function findPhrase(t) {
244 // Hot path: same phrase still active
245 if (t >= TIMELINE[lastIdx].startTime && t < TIMELINE[lastIdx].endTime) {
246 return TIMELINE[lastIdx];
247 }
248 // Cold path: linear scan (handles forward + backward seeks)
249 for (let i = 0; i < TIMELINE.length; i++) {
250 if (t >= TIMELINE[i].startTime && t < TIMELINE[i].endTime) {
251 lastIdx = i;
252 return TIMELINE[i];
253 }
254 }
255 return null;
256 }
257
258 function renderAt(t) {
259 // ----- Cursor blink (square wave, runs always) -----
260 cursorEl.style.opacity = t % BLINK_CYCLE < BLINK_CYCLE / 2 ? "1" : "0";
261
262 // ----- Find current phrase -----
263 const phrase = findPhrase(t);
264 if (!phrase) {
265 if (mainEl.textContent !== "") mainEl.textContent = "";
266 if (accentEl.textContent !== "") accentEl.textContent = "";
267 cursorEl.style.background = MAIN_COLOR;
268 return;
269 }
270
271 // ----- Compute visible characters -----
272 const activeT = t - phrase.startTime;
273 const charIdx = Math.floor(activeT / phrase.charSpeed);
274 const mainLen = phrase.textMain.length;
275
276 const visMain = phrase.textMain.slice(0, Math.min(charIdx, mainLen));
277 const accentLen = Math.max(0, charIdx - mainLen);
278 const visAccent = phrase.textAccent.slice(0, accentLen);
279
280 // ----- DOM writes (guarded) -----
281 if (mainEl.textContent !== visMain) mainEl.textContent = visMain;
282 if (accentEl.textContent !== visAccent) accentEl.textContent = visAccent;
283
284 // ----- Cursor color follows active segment -----
285 const inAccent = visMain.length === mainLen && visAccent.length > 0;
286 cursorEl.style.background = inAccent ? ACCENT_COLOR : MAIN_COLOR;
287 }
288
289 tl.to(
290 { tick: 0 },
291 {
292 tick: 1,
293 duration: 7.5, // matches data-duration; clips the timeline
294 ease: "none",
295 onUpdate: () => renderAt(tl.time()),
296 },
297 0,
298 );
299
300 const pauseTimeline = tl.pause.bind(tl);
301 tl.pause = (time, suppressEvents) => {
302 const result = pauseTimeline(time, suppressEvents);
303 if (typeof time === "number") renderAt(time);
304 return result;
305 };
306
307 function wrapSeekOwner(owner) {
308 if (!owner || owner.__multiPhraseSeekWrapped) return;
309 let currentSeek = owner.seek;
310 Object.defineProperty(owner, "seek", {
311 configurable: true,
312 get: () => currentSeek,
313 set: (value) => {
314 if (typeof value !== "function") {
315 currentSeek = value;
316 return;
317 }
318 currentSeek = function (time) {
319 const result = value.apply(this, arguments);
320 if (typeof time === "number") renderAt(time);
321 return result;
322 };
323 },
324 });
325 owner.seek = currentSeek;
326 owner.__multiPhraseSeekWrapped = true;
327 }
328
329 function installWindowSeekHook(key) {
330 if (window[key]) {
331 wrapSeekOwner(window[key]);
332 return;
333 }
334 let pendingOwner;
335 Object.defineProperty(window, key, {
336 configurable: true,
337 get: () => pendingOwner,
338 set: (value) => {
339 pendingOwner = value;
340 wrapSeekOwner(value);
341 },
342 });
343 }
344 installWindowSeekHook("__player");
345 installWindowSeekHook("__hf");
346
347 renderAt(0);
348
349 window.__timelines["main"] = tl;
350 </script>
351 </body>
352</html>